diff --git a/AGENTS.md b/AGENTS.md index 20b2689a401..4ff990a5542 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ a module map, read/write paths, change-coupling points, and gotchas: - [`src/servers/AGENTS.md`](src/servers/AGENTS.md) - [`src/operator/AGENTS.md`](src/operator/AGENTS.md) - [`src/mito2/AGENTS.md`](src/mito2/AGENTS.md) +- [`src/promql/AGENTS.md`](src/promql/AGENTS.md) - [`src/metric-engine/AGENTS.md`](src/metric-engine/AGENTS.md) - [`src/flow/AGENTS.md`](src/flow/AGENTS.md) - [`src/frontend/AGENTS.md`](src/frontend/AGENTS.md) diff --git a/src/common/meta/src/node_manager.rs b/src/common/meta/src/node_manager.rs index 512952c9c99..d18e2cc8471 100644 --- a/src/common/meta/src/node_manager.rs +++ b/src/common/meta/src/node_manager.rs @@ -18,21 +18,10 @@ use api::region::RegionResponse; use api::v1::flow::{DirtyWindowRequests, FlowRequest, FlowResponse}; use api::v1::region::{InsertRequests, RegionRequest}; pub use common_base::AffectedRows; -use common_error::ext::{BoxedError, PlainError}; -use common_error::status_code::StatusCode; -use common_query::error::Error as CommonQueryError; -use common_query::request::{ - PreparedNamedRawSeries, QueryRequest, RAW_SERIES_REQUEST_EXTENSION_KEY, RawSeriesRequest, -}; -use common_query::series_frame::{raw_series_frame_ordering, unpack_raw_series}; +use common_query::request::QueryRequest; use common_recordbatch::SendableRecordBatchStream; -use common_recordbatch::adapter::DfRecordBatchStreamAdapter; -use datatypes::series::SeriesOrdering; -use datatypes::series::samples::SeriesSchema; -use snafu::{ResultExt, location}; -use store_api::region_engine::{RawLabelSource, SendableRawSeriesStream}; -use crate::error::{ExternalSnafu, Result}; +use crate::error::Result; use crate::peer::Peer; /// The trait for handling requests to datanode. @@ -43,111 +32,6 @@ pub trait Datanode: Send + Sync { /// Handles query requests async fn handle_query(&self, request: QueryRequest) -> Result; - - /// Prepares a bound raw-series query without executing it. - /// - /// Implementations that do not support preflight return `Ok(None)` without execution or a - /// validation guarantee; failures other than unsupported preparation are returned as errors. - async fn prepare_raw_series_query( - &self, - _request: QueryRequest, - _binding: RawSeriesRequest, - ) -> Result> { - Ok(None) - } - - /// Non-consumingly checks whether a named raw-series preflight is supported. - /// - /// Returns the approved binding and ordering on support, or `None` when the request is - /// explicitly unsupported; failures that are not unsupported preparation are returned as - /// errors. This point-in-time check does not reserve a plan, and execution revalidates - /// support. - async fn prepare_named_raw_series_query( - &self, - _request: QueryRequest, - ) -> Result> { - Ok(None) - } - - /// Handles a bound raw-series query and decodes its series frames. - /// - /// The binding is an expectation supplied by the already-authorized internal planner; it is - /// not evidence of provenance or an independent authorization grant. - async fn handle_raw_series_query( - &self, - request: QueryRequest, - binding: RawSeriesRequest, - ) -> Result { - let (request, expected_schema, label_source) = bind_raw_series_query(request, &binding)?; - let stream = self.handle_query(request).await?; - let ordering = raw_series_frame_ordering(stream.schema().arrow_schema().as_ref()); - let stream = Box::pin(DfRecordBatchStreamAdapter::new(stream)); - ordering - .and_then(|ordering| unpack_raw_series(stream, expected_schema, label_source, ordering)) - .map_err(|error| { - BoxedError::new(CommonQueryError::GeneralDataFusion { - error, - location: location!(), - }) - }) - .context(ExternalSnafu) - } -} - -/// Binds a raw-series request to the query context without executing the query. -pub fn bind_raw_series_query( - mut request: QueryRequest, - binding: &RawSeriesRequest, -) -> Result<(QueryRequest, Arc, RawLabelSource)> { - if request.region_id != binding.physical_metadata.region_id { - return Err(BoxedError::new(PlainError::new( - "raw-series request region does not match binding".to_string(), - StatusCode::InvalidArguments, - ))) - .context(ExternalSnafu); - } - let (expected_schema, label_source) = binding - .bind(Arc::new(binding.physical_metadata.clone())) - .map_err(|error| { - BoxedError::new(CommonQueryError::GeneralDataFusion { - error, - location: location!(), - }) - }) - .context(ExternalSnafu)?; - if request - .header - .as_ref() - .and_then(|header| header.query_context.as_ref()) - .is_some_and(|context| { - context - .extensions - .contains_key(RAW_SERIES_REQUEST_EXTENSION_KEY) - }) - { - return Err(BoxedError::new(PlainError::new( - "raw-series request marker already exists".to_string(), - StatusCode::InvalidArguments, - ))) - .context(ExternalSnafu); - } - let extension = binding - .to_extension_value() - .map_err(|error| { - BoxedError::new(CommonQueryError::GeneralDataFusion { - error, - location: location!(), - }) - }) - .context(ExternalSnafu)?; - request - .header - .get_or_insert_default() - .query_context - .get_or_insert_default() - .extensions - .insert(RAW_SERIES_REQUEST_EXTENSION_KEY.to_string(), extension); - Ok((request, expected_schema, label_source)) } impl dyn Datanode + '_ { diff --git a/src/common/query/src/request.rs b/src/common/query/src/request.rs index 829fa40b22d..77971440447 100644 --- a/src/common/query/src/request.rs +++ b/src/common/query/src/request.rs @@ -14,16 +14,9 @@ mod base64_serde; mod initial_remote_dyn_filter_reg; -mod named_raw_series; mod raw_series_request; -pub use self::named_raw_series::{ - PREPARE_RAW_SERIES_ACTION, PreparedNamedRawSeries, decode_prepare_raw_series_response, - encode_prepare_raw_series_response, -}; -pub use self::raw_series_request::{ - RAW_SERIES_REQUEST_EXTENSION_KEY, RawSeriesRequest, RawSeriesTag, take_raw_series_request, -}; +pub use self::raw_series_request::{RawSeriesRequest, RawSeriesTag}; use std::cell::Cell; use std::sync::Arc; diff --git a/src/common/query/src/request/named_raw_series.rs b/src/common/query/src/request/named_raw_series.rs deleted file mode 100644 index 1a6cf8f8521..00000000000 --- a/src/common/query/src/request/named_raw_series.rs +++ /dev/null @@ -1,387 +0,0 @@ -// 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. - -//! Named raw-series preflight contract. -//! -//! The contract is the JSON body of the [`PREPARE_RAW_SERIES_ACTION`] action. -//! It carries the already-bound raw-series request and the ordering the -//! approving node can guarantee for it, or an explicit unsupported marker. - -use datafusion_common::{DataFusionError, Result as DataFusionResult}; -use datatypes::series::SeriesOrdering; -use serde::{Deserialize, Deserializer, Serialize}; - -use crate::request::RawSeriesRequest; - -/// Action name of the named raw-series preflight contract. -pub const PREPARE_RAW_SERIES_ACTION: &str = "greptime.prepare_raw_series.v1"; - -/// A named raw-series request approved for preflight. -#[derive(Clone, Debug)] -pub struct PreparedNamedRawSeries { - /// The bound raw-series request approved by the preparing node. - pub binding: RawSeriesRequest, - /// The ordering guaranteed by the preparing node for the binding. - pub ordering: SeriesOrdering, -} - -/// Encodes a preflight response body for [`PREPARE_RAW_SERIES_ACTION`]. -/// -/// `Some` approves the request after revalidating its binding through the -/// existing [`RawSeriesRequest`] validation, while `None` encodes the frozen -/// unsupported shape. Not every [`SeriesOrdering`] can be reported: interleaved -/// orderings are rejected. -pub fn encode_prepare_raw_series_response( - supported: Option<&PreparedNamedRawSeries>, -) -> DataFusionResult> { - let supported = match supported { - Some(prepared) => { - prepared.binding.to_extension_value()?; - Some(Supported { - binding: prepared.binding.clone(), - ordering: WireOrdering::from_series_ordering(prepared.ordering)?, - }) - } - None => None, - }; - let response = PrepareRawSeriesResponse { - version: PREPARE_RAW_SERIES_VERSION, - supported, - }; - serde_json::to_vec(&response).map_err(|_| invalid_response_error()) -} - -/// Decodes a preflight response body for [`PREPARE_RAW_SERIES_ACTION`]. -/// -/// Returns `Some` for an approving body and `None` for the frozen unsupported -/// shape. The body is decoded directly as a typed JSON envelope, so duplicate -/// keys, unknown fields, unknown versions, and malformed bindings are rejected. -pub fn decode_prepare_raw_series_response( - body: &[u8], -) -> DataFusionResult> { - let response: PrepareRawSeriesResponse = - serde_json::from_slice(body).map_err(|_| invalid_response_error())?; - if response.version != PREPARE_RAW_SERIES_VERSION { - return Err(unsupported_version_error()); - } - let Some(supported) = response.supported else { - return Ok(None); - }; - supported.binding.to_extension_value()?; - Ok(Some(PreparedNamedRawSeries { - binding: supported.binding, - ordering: supported.ordering.into_series_ordering(), - })) -} - -/// Wire-format version of the named raw-series preflight contract. -const PREPARE_RAW_SERIES_VERSION: u32 = 1; - -/// Private JSON envelope of [`PREPARE_RAW_SERIES_ACTION`]. -/// -/// `supported` is required but nullable: a missing field is rejected instead of -/// defaulting, while an explicit `null` is the unsupported encoding. -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct PrepareRawSeriesResponse { - version: u32, - #[serde(deserialize_with = "deserialize_required_supported")] - supported: Option, -} - -/// Private shape of an approving response. -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Supported { - binding: RawSeriesRequest, - ordering: WireOrdering, -} - -/// Private mirror of the orderings this contract can report. -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum WireOrdering { - Contiguous, - ReadKeyThenTime, -} - -impl WireOrdering { - /// Rejects orderings the contract cannot report. - fn from_series_ordering(ordering: SeriesOrdering) -> DataFusionResult { - match ordering { - SeriesOrdering::Contiguous => Ok(Self::Contiguous), - SeriesOrdering::KeyThenTime => Ok(Self::ReadKeyThenTime), - SeriesOrdering::Interleaved => Err(unsupported_ordering_error()), - } - } - - fn into_series_ordering(self) -> SeriesOrdering { - match self { - Self::Contiguous => SeriesOrdering::Contiguous, - Self::ReadKeyThenTime => SeriesOrdering::KeyThenTime, - } - } -} - -/// Requires the `supported` field while still accepting an explicit `null`. -fn deserialize_required_supported<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Option::::deserialize(deserializer) -} - -fn invalid_response_error() -> DataFusionError { - DataFusionError::Plan("Invalid prepare raw-series response".to_string()) -} - -fn unsupported_version_error() -> DataFusionError { - DataFusionError::Plan("Unsupported prepare raw-series response version".to_string()) -} - -fn unsupported_ordering_error() -> DataFusionError { - DataFusionError::Plan("Unsupported prepare raw-series response ordering".to_string()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use store_api::storage::RegionId; - - use super::*; - - const SUPPORTED_FIXTURE: &str = include_str!( - "../../../../../tests/compatibility/wire/named_raw_series_prepare/v1_supported.json" - ); - const UNSUPPORTED_FIXTURE: &str = include_str!( - "../../../../../tests/compatibility/wire/named_raw_series_prepare/v1_unsupported.json" - ); - const BINDING_FIXTURE: &str = - include_str!("../../../../../tests/compatibility/wire/raw_series_request/v1_request.json"); - - fn fixture_binding() -> RawSeriesRequest { - RawSeriesRequest::from_extension_value(BINDING_FIXTURE).unwrap() - } - - fn fixture_body() -> String { - format!( - r#"{{"version":1,"supported":{{"binding":{},"ordering":"contiguous"}}}}"#, - BINDING_FIXTURE.trim() - ) - } - - #[test] - fn frozen_fixtures_decode_supported_and_unsupported() { - let prepared = decode_prepare_raw_series_response(SUPPORTED_FIXTURE.as_bytes()) - .unwrap() - .expect("supported fixture must decode as approved"); - assert_eq!(prepared.ordering, SeriesOrdering::Contiguous); - assert_eq!( - prepared.binding.physical_metadata.region_id, - RegionId::new(1, 1) - ); - assert_eq!( - prepared.binding.to_extension_value().unwrap(), - fixture_binding().to_extension_value().unwrap() - ); - - let (schema, _) = prepared - .binding - .bind(Arc::new(prepared.binding.physical_metadata.clone())) - .unwrap(); - assert_eq!( - schema - .fields - .iter() - .map(|field| field.name.as_ref()) - .collect::>(), - ["aux", "value"] - ); - - assert!( - decode_prepare_raw_series_response(UNSUPPORTED_FIXTURE.as_bytes()) - .unwrap() - .is_none() - ); - } - - #[test] - fn prepare_response_round_trips_both_orderings() { - for (ordering, wire_name) in [ - (SeriesOrdering::Contiguous, "contiguous"), - (SeriesOrdering::KeyThenTime, "read_key_then_time"), - ] { - let prepared = PreparedNamedRawSeries { - binding: fixture_binding(), - ordering, - }; - let encoded = encode_prepare_raw_series_response(Some(&prepared)).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); - assert_eq!(json["version"].as_u64(), Some(1)); - assert_eq!(json["supported"]["ordering"].as_str(), Some(wire_name)); - assert!(json["supported"]["binding"].is_object()); - - let decoded = decode_prepare_raw_series_response(&encoded) - .unwrap() - .expect("approved response must decode as supported"); - assert_eq!(decoded.ordering, ordering); - assert_eq!( - decoded.binding.to_extension_value().unwrap(), - prepared.binding.to_extension_value().unwrap() - ); - } - } - - #[test] - fn unsupported_prepare_response_has_frozen_shape() { - let encoded = encode_prepare_raw_series_response(None).unwrap(); - assert_eq!( - std::str::from_utf8(&encoded).unwrap(), - r#"{"version":1,"supported":null}"# - ); - assert!( - decode_prepare_raw_series_response(&encoded) - .unwrap() - .is_none() - ); - } - - #[test] - fn prepare_response_encoding_rejects_interleaved_and_malformed_binding() { - let interleaved = PreparedNamedRawSeries { - binding: fixture_binding(), - ordering: SeriesOrdering::Interleaved, - }; - assert!(encode_prepare_raw_series_response(Some(&interleaved)).is_err()); - - let mut malformed = fixture_binding(); - malformed.field_ids.push(3); - let malformed = PreparedNamedRawSeries { - binding: malformed, - ordering: SeriesOrdering::Contiguous, - }; - assert!(encode_prepare_raw_series_response(Some(&malformed)).is_err()); - } - - #[test] - fn prepare_response_rejects_malformed_bodies() { - let body = fixture_body(); - let cases = [ - ("empty body", String::new()), - ("not json", "{".to_string()), - ("top-level array", "[]".to_string()), - ("missing version", r#"{"supported":null}"#.to_string()), - ( - "zero version", - r#"{"version":0,"supported":null}"#.to_string(), - ), - ( - "unknown version", - r#"{"version":2,"supported":null}"#.to_string(), - ), - ( - "unknown envelope field", - r#"{"version":1,"supported":null,"extra":true}"#.to_string(), - ), - ("missing supported", r#"{"version":1}"#.to_string()), - ( - "duplicate version", - r#"{"version":1,"version":1,"supported":null}"#.to_string(), - ), - ( - "duplicate supported", - r#"{"version":1,"supported":null,"supported":null}"#.to_string(), - ), - ( - "supported missing binding", - r#"{"version":1,"supported":{"ordering":"contiguous"}}"#.to_string(), - ), - ( - "supported missing ordering", - body.replace(r#","ordering":"contiguous""#, ""), - ), - ( - "supported unknown field", - body.replace( - r#""ordering":"contiguous""#, - r#""ordering":"contiguous","extra":true"#, - ), - ), - ( - "interleaved ordering", - body.replace(r#""ordering":"contiguous""#, r#""ordering":"interleaved""#), - ), - ( - "unknown ordering", - body.replace(r#""ordering":"contiguous""#, r#""ordering":"future""#), - ), - ( - "non-string ordering", - body.replace(r#""ordering":"contiguous""#, r#""ordering":1"#), - ), - ( - "unknown nested binding field", - body.replace( - r#""metric_name":"cpu""#, - r#""metric_name":"cpu","extra":true"#, - ), - ), - ( - "duplicate nested binding field", - body.replace( - r#""metric_name":"cpu""#, - r#""metric_name":"cpu","metric_name":"cpu""#, - ), - ), - ( - "unknown nested tag field", - body.replace( - r#"{"column_id":1,"name":"host"}"#, - r#"{"column_id":1,"name":"host","extra":true}"#, - ), - ), - ( - "duplicate nested tag field", - body.replace( - r#"{"column_id":1,"name":"host"}"#, - r#"{"column_id":1,"name":"host","name":"host"}"#, - ), - ), - ( - "malformed binding version", - body.replace( - r#"{"version":1,"physical_metadata""#, - r#"{"version":2,"physical_metadata""#, - ), - ), - ( - "duplicate binding field id", - body.replace(r#""field_ids":[3,2]"#, r#""field_ids":[3,3]"#), - ), - ( - "non-field binding id", - body.replace(r#""field_ids":[3,2]"#, r#""field_ids":[1]"#), - ), - ]; - - for (name, candidate) in cases { - assert!( - decode_prepare_raw_series_response(candidate.as_bytes()).is_err(), - "must reject {name}" - ); - } - assert!(decode_prepare_raw_series_response(body.as_bytes()).is_ok()); - } -} diff --git a/src/common/query/src/request/raw_series_request.rs b/src/common/query/src/request/raw_series_request.rs index 2631703088b..0dd02b5290d 100644 --- a/src/common/query/src/request/raw_series_request.rs +++ b/src/common/query/src/request/raw_series_request.rs @@ -16,7 +16,6 @@ use std::collections::HashSet; use std::sync::Arc; use api::v1::SemanticType; -use api::v1::region::RegionRequestHeader; use datafusion_common::{DataFusionError, Result as DataFusionResult}; use datatypes::series::samples::{FieldSpec, SeriesSchema}; use serde::{Deserialize, Serialize}; @@ -24,9 +23,6 @@ use store_api::metadata::{RegionMetadata, RegionMetadataRef}; use store_api::region_engine::{BoundTag, RawLabelSource}; use store_api::storage::ColumnId; -/// Reserved query-context extension containing an explicit internal raw-series request. -pub const RAW_SERIES_REQUEST_EXTENSION_KEY: &str = "greptime.raw_series_request"; - /// A logical label mapped to its physical region column. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -54,21 +50,6 @@ pub struct RawSeriesRequest { } impl RawSeriesRequest { - /// Serializes the control after validating its version and field binding. - pub fn to_extension_value(&self) -> DataFusionResult { - self.validate()?; - serde_json::to_string(self) - .map_err(|_| DataFusionError::Plan("Invalid raw-series request".into())) - } - - /// Parses and validates a strict versioned control value. - pub fn from_extension_value(value: &str) -> DataFusionResult { - let request: Self = serde_json::from_str(value) - .map_err(|_| DataFusionError::Plan("Invalid raw-series request".into()))?; - request.validate()?; - Ok(request) - } - /// Binds expected output fields and labels to the actual opened scanner metadata. pub fn bind( &self, @@ -138,83 +119,87 @@ fn same_encoding_identity(left: &RegionMetadata, right: &RegionMetadata) -> bool && left.time_index_type() == right.time_index_type() } -/// Removes and parses the optional raw control from a mutable request header. -pub fn take_raw_series_request( - header: &mut Option, -) -> DataFusionResult> { - let Some(value) = header - .as_mut() - .and_then(|header| header.query_context.as_mut()) - .and_then(|context| context.extensions.remove(RAW_SERIES_REQUEST_EXTENSION_KEY)) - else { - return Ok(None); - }; - RawSeriesRequest::from_extension_value(&value).map(Some) -} - #[cfg(test)] mod tests { use datatypes::data_type::ConcreteDataType; use datatypes::schema::ColumnSchema; + use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder}; use store_api::storage::RegionId; use super::*; - #[test] - fn strict_versioned_extension_fixture_is_parseable() { - let fixture = include_str!( - "../../../../../tests/compatibility/wire/raw_series_request/v1_request.json" - ); - let request = RawSeriesRequest::from_extension_value(fixture).unwrap(); - assert_eq!(request.version, 1); - assert_eq!(request.metric_name, "cpu"); - assert_eq!(request.logical_tags[0].name, "host"); - assert_eq!(request.logical_tags[0].column_id, 1); - assert_eq!(request.field_ids, [3, 2]); - assert_eq!(request.physical_metadata.region_id, RegionId::new(1, 1)); - } - - #[test] - fn absent_marker_remains_an_ordinary_request() { - let mut header = None; - assert!(take_raw_series_request(&mut header).unwrap().is_none()); + /// Builds the physical metadata of a one-tag, two-field region. + fn fixture_metadata() -> RegionMetadata { + let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 1)); + builder + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "host", + ConcreteDataType::string_datatype(), + false, + ), + semantic_type: SemanticType::Tag, + column_id: 1, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "value", + ConcreteDataType::int32_datatype(), + false, + ), + semantic_type: SemanticType::Field, + column_id: 2, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new("aux", ConcreteDataType::int64_datatype(), false), + semantic_type: SemanticType::Field, + column_id: 3, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ), + semantic_type: SemanticType::Timestamp, + column_id: 4, + }) + .primary_key(vec![1]); + builder.build().unwrap() } + /// Builds a request asking for fields in descending column order. fn fixture_request() -> RawSeriesRequest { - let fixture = include_str!( - "../../../../../tests/compatibility/wire/raw_series_request/v1_request.json" - ); - RawSeriesRequest::from_extension_value(fixture).unwrap() + RawSeriesRequest { + version: 1, + physical_metadata: fixture_metadata(), + metric_name: "cpu".to_string(), + logical_tags: vec![RawSeriesTag { + column_id: 1, + name: "host".to_string(), + }], + field_ids: vec![3, 2], + } } #[test] - fn rejects_unknown_fields_and_versions() { - let mut version: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../tests/compatibility/wire/raw_series_request/v1_request.json" - )) - .unwrap(); - version["version"] = serde_json::json!(2); - assert!(RawSeriesRequest::from_extension_value(&version.to_string()).is_err()); - - let mut unknown: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../tests/compatibility/wire/raw_series_request/v1_request.json" - )) - .unwrap(); - unknown["logical_tags"][0]["extra"] = serde_json::json!(true); - assert!(RawSeriesRequest::from_extension_value(&unknown.to_string()).is_err()); + fn rejects_unsupported_version() { + let mut request = fixture_request(); + request.version = 2; + assert!(request.validate().is_err()); } #[test] fn rejects_empty_duplicate_and_non_field_ids() { let mut request = fixture_request(); request.field_ids.clear(); - assert!(request.to_extension_value().is_err()); + assert!(request.validate().is_err()); let mut request = fixture_request(); request.field_ids.push(3); - assert!(request.to_extension_value().is_err()); + assert!(request.validate().is_err()); let mut request = fixture_request(); request.field_ids = vec![1]; - assert!(request.to_extension_value().is_err()); + assert!(request.validate().is_err()); } #[test] diff --git a/src/common/query/src/series_frame.rs b/src/common/query/src/series_frame.rs index e202e78c4ca..d787106c634 100644 --- a/src/common/query/src/series_frame.rs +++ b/src/common/query/src/series_frame.rs @@ -15,10 +15,18 @@ //! Streaming boundary between finite raw-series chunks and DataFusion record batches. //! //! Schema fields identify the ordered logical payload fields and codec layout; additional -//! application metadata is permitted when stable across the stream. The caller-provided -//! [`RawLabelSource`] is the label binding, not authenticated by the frame schema or field -//! provenance. This adapter preserves scan candidates only; it is not a residual filter, -//! authorization check, remote transport, or compaction boundary. Only +//! application metadata is permitted when stable across the stream. A key-encoded frame also +//! declares its own label decode description, and the frame is the sole source of decode +//! information: [`unpack_raw_series`] reads the fields, the label mode, the description, and the +//! producer ordering from the received schema, keeps the description on the returned stream, and +//! therefore needs no [`RawLabelSource`]. A caller that holds a scan-bound identity — the physical +//! region key layout a decoded identity came from — compares that identity's declared label +//! decode layout with [`ensure_frame_matches_label_source`] instead of passing it as a decode +//! input; that comparison is equality of the description alone and proves nothing about region +//! identity or provenance, and neither side is authenticated as field provenance. This adapter +//! preserves +//! scan candidates only; it is not a residual filter, authorization check, remote transport, or +//! compaction boundary. Only //! [`SeriesOrdering::Contiguous`] and [`SeriesOrdering::KeyThenTime`] are accepted; //! `Interleaved` is rejected before any poll. @@ -32,14 +40,19 @@ use datafusion_common::DataFusionError; use datatypes::arrow::datatypes::{Schema as ArrowSchema, SchemaRef as ArrowSchemaRef}; use datatypes::error::InvalidSeriesSnafu; use datatypes::series::frame::{ + LABEL_DECODE_VERSION, LabelDecodeColumn, LabelDecodeEncoding, LabelDecodeSpec, LabelDecodeTag, RawLabelMode, RawSeriesChunkCursor, RawSeriesFrameBuilder, RawSeriesFrameDecoder, - raw_series_frame_schema, + raw_series_frame_label_decode, raw_series_frame_schema_with_label_decode, + raw_series_frame_series_schema, }; use datatypes::series::raw::RawSeriesChunk; use datatypes::series::samples::SeriesSchema; use datatypes::series::{MetricName, SeriesOrdering}; use futures_util::Stream; -use store_api::region_engine::{RawLabelSource, RawSeriesStream, SendableRawSeriesStream}; +use store_api::codec::PrimaryKeyEncoding; +use store_api::region_engine::{ + NO_BOUND_LABEL_SOURCE, RawLabelSource, RawSeriesStream, SendableRawSeriesStream, +}; mod record_batches; @@ -50,13 +63,35 @@ mod tests; const ORDERING_KEY: &str = "greptime.series.ordering"; -/// Builds the canonical raw frame schema with the producer's guaranteed ordering. +/// Builds the canonical raw frame schema with the producer's label binding and ordering. /// /// Ordering records a producer guarantee; it is not inferred from frame contents. `Interleaved` -/// has no v1 wire representation and is rejected before the schema is assembled. +/// has no v1 wire representation and is rejected before the schema is assembled. The label binding +/// determines both the label mode and, for a primary-key binding, the self-describing label decode +/// description written into the schema, so a frame can never declare a label layout it does not +/// describe. pub fn raw_series_frame_schema_with_ordering( + schema: &SeriesSchema, + label_source: &RawLabelSource, + ordering: SeriesOrdering, +) -> datafusion_common::Result { + frame_schema_with( + schema, + label_mode(label_source), + label_decode_spec(label_source).as_ref(), + ordering, + ) +} + +/// Assembles a declared frame schema from an explicit label mode and decode description. +/// +/// The mode and description pair is checked by +/// [`raw_series_frame_schema_with_label_decode`], so a key mode without a description and an +/// explicit mode with one are both rejected here instead of reaching the wire. +fn frame_schema_with( schema: &SeriesSchema, mode: RawLabelMode, + decode: Option<&LabelDecodeSpec>, ordering: SeriesOrdering, ) -> datafusion_common::Result { let ordering = match ordering { @@ -68,7 +103,8 @@ pub fn raw_series_frame_schema_with_ordering( )); } }; - let schema = raw_series_frame_schema(schema, mode).map_err(codec_error)?; + let schema = + raw_series_frame_schema_with_label_decode(schema, mode, decode).map_err(codec_error)?; let mut metadata = schema.metadata().clone(); metadata.insert(ORDERING_KEY.to_string(), ordering.to_string()); Ok(Arc::new(ArrowSchema::new_with_metadata( @@ -77,6 +113,56 @@ pub fn raw_series_frame_schema_with_ordering( ))) } +/// Derives the label decode description a frame must carry for this binding. +/// +/// A primary-key binding reduces to exactly what decoding reads: the metric name, the primary-key +/// encoding, the ordered key columns with their physical types, and the logical tags. Region +/// metadata guarantees the key list holds every tag column and nothing else, so the description +/// needs no per-column role. Explicit bindings already carry complete labels and declare no +/// description. +/// +/// The result is canonical: its logical tags are in [`LabelDecodeSpec::canonicalize`] order, which +/// is what the writer serializes, so a derived description and a received one for the same labels +/// are the same value and the same text. Derivation cannot fail, because a physical type is copied +/// rather than translated; a description a reader cannot honour is rejected when it is read, and a +/// decoder rejects the tags it cannot bind. +pub fn label_decode_spec(source: &RawLabelSource) -> Option { + let RawLabelSource::PrimaryKey { + metric_name, + physical_metadata, + logical_tags, + } = source + else { + return None; + }; + let primary_key = physical_metadata + .primary_key_columns() + .map(|column| LabelDecodeColumn { + column_id: column.column_id, + name: column.column_schema.name.clone(), + physical_type: column.column_schema.data_type.clone(), + }) + .collect(); + let mut description = LabelDecodeSpec { + version: LABEL_DECODE_VERSION, + metric_name: metric_name.to_string(), + encoding: match physical_metadata.primary_key_encoding { + PrimaryKeyEncoding::Dense => LabelDecodeEncoding::Dense, + PrimaryKeyEncoding::Sparse => LabelDecodeEncoding::Sparse, + }, + primary_key, + logical_tags: logical_tags + .iter() + .map(|tag| LabelDecodeTag { + column_id: tag.column_id, + name: tag.name.to_string(), + }) + .collect(), + }; + description.canonicalize(); + Some(description) +} + /// Reads the declared producer ordering, conservatively treating legacy frames as contiguous. /// /// Only the two orderings representable on the wire can be returned. @@ -99,17 +185,23 @@ pub fn raw_series_frame_ordering( /// work bound, not a resident-memory cap. A ready batch is yielded without polling the source /// again. Producers must keep each series' chunks contiguous. Validation checks adjacent chunk /// conditions but does not certify nonadjacent recurrence for `Contiguous` ordering. +/// +/// The stream's own declaration is the only decode input: a frame unpacked without a scan-bound +/// [`RawLabelSource`] round trips unchanged instead of being re-declared from a binding it does not +/// have, and nothing is derived from a binding here. The declaration also decides the frame's label +/// mode, so a chunk whose labels disagree with it is rejected when that chunk is coded. pub fn pack_raw_series( source: SendableRawSeriesStream, ) -> datafusion_common::Result { let ordering = source.ordering(); ensure_supported_ordering(ordering)?; let schema = source.schema().clone(); - let label_source = source.label_source().clone(); - let mode = label_mode(&label_source); - // The declared frame schema carries the producer ordering; the shared builder's schema - // intentionally omits it, so every ready batch is declared before it is yielded. - let frame_schema = raw_series_frame_schema_with_ordering(&schema, mode, ordering)?; + let decode = source.label_decode_spec().cloned(); + let mode = declared_label_mode(&decode); + // The declared frame schema carries the producer ordering and the label decode description; + // the shared builder's schema intentionally omits both, so every ready batch is declared under + // this schema before it is yielded, including when no row is ever produced. + let frame_schema = frame_schema_with(&schema, mode, decode.as_ref(), ordering)?; let builder = RawSeriesFrameBuilder::try_new( &schema, mode, @@ -129,33 +221,48 @@ pub fn pack_raw_series( })) } -/// Unpacks DataFusion batches using the caller's explicit scan binding and ordering. +/// Unpacks DataFusion batches declared by [`pack_raw_series`]. /// -/// Only [`SeriesOrdering::Contiguous`] and [`SeriesOrdering::KeyThenTime`] streams are -/// accepted; an `Interleaved` declaration is rejected before the stream is returned, including -/// for an empty stream. Producers must keep each series' chunks contiguous. Validation checks -/// adjacent chunk conditions but does not certify nonadjacent recurrence for `Contiguous` -/// ordering. +/// The frame is the sole source of decode information: the payload fields, the label mode, the +/// label decode description, and the producer ordering are all read from the stream's own schema, +/// so unpacking a frame a producer wrote needs no local copy of its binding. Only +/// [`SeriesOrdering::Contiguous`] and [`SeriesOrdering::KeyThenTime`] streams are accepted; an +/// `Interleaved` declaration is rejected before the stream is returned, including for an empty +/// stream. Producers must keep each series' chunks contiguous. Validation checks adjacent chunk +/// conditions but does not certify nonadjacent recurrence for `Contiguous` ordering. /// -/// The supplied binding is retained unchanged and is not validated as field provenance. +/// Every frame is validated here, unconditionally. The declared layout must be this revision's, the +/// label decode description must parse, name a known version, and agree with the declared label +/// mode — a key frame must declare one and an explicit frame must not, since explicit labels are +/// already complete. The declared fields and the empty batch are then decoded once, so a stream that +/// could not be decoded is rejected before it is returned. +/// +/// `expected` is a check, never an input. When it is given it must match the schema the frame +/// declares — the same logical fields in the same order, and no evaluation grid — and a mismatch is +/// an error. The decoder and the returned stream's schema are built from the frame either way, so a +/// caller cannot supply a field layout the frame does not declare. pub fn unpack_raw_series( source: DfSendableRecordBatchStream, - expected: Arc, - label_source: RawLabelSource, - ordering: SeriesOrdering, + expected: Option>, ) -> datafusion_common::Result { - ensure_supported_ordering(ordering)?; - let mode = label_mode(&label_source); let frame_schema = source.schema(); - let decoder = RawSeriesFrameDecoder::try_new(&expected, mode).map_err(codec_error)?; + let declared = raw_series_frame_label_decode(frame_schema.as_ref()).map_err(codec_error)?; + let mode = declared_label_mode(&declared); + let schema = + Arc::new(raw_series_frame_series_schema(frame_schema.as_ref()).map_err(codec_error)?); + if let Some(expected) = &expected { + ensure_declared_schema(&schema, expected)?; + } + let ordering = raw_series_frame_ordering(frame_schema.as_ref())?; + let decoder = RawSeriesFrameDecoder::try_new(&schema, mode).map_err(codec_error)?; // Validate even empty streams' declared schema and layout before returning the stream. let empty = DfRecordBatch::new_empty(frame_schema.clone()); decoder.decode(&empty).map_err(codec_error)?; Ok(Box::pin(UnpackStream { source: Some(source), - schema: expected, + schema, frame_schema, - label_source, + label_decode_spec: declared, ordering, validator: SequenceValidator::new(ordering), pending: Vec::new().into_iter(), @@ -163,6 +270,66 @@ pub fn unpack_raw_series( })) } +/// Rejects an `expected` series schema the frame does not declare. +/// +/// Only the two things a caller can get wrong are checked, because those are the only two things +/// [`SeriesSchema`] holds: the logical fields and their order, and an evaluation grid a raw frame +/// cannot carry. +fn ensure_declared_schema( + declared: &SeriesSchema, + expected: &SeriesSchema, +) -> datafusion_common::Result<()> { + if expected.eval_grid.is_some() { + return Err(invalid( + "expected raw series schema declares an evaluation grid the frame cannot carry", + )); + } + if expected.fields.len() != declared.fields.len() + || expected + .fields + .iter() + .zip(declared.fields.iter()) + .any(|(expected, declared)| expected.name != declared.name) + { + return Err(invalid( + "raw frame fields do not match the expected series schema", + )); + } + Ok(()) +} + +/// Checks that a frame declares the label decode layout a scan-bound binding describes. +/// +/// This compares one thing: the description parsed from the frame's +/// `greptime.series.label_decode` metadata against the description derived from `label_source`, by +/// plain equality. A difference in the version, the metric name, the primary-key encoding, the +/// ordered key columns and their physical types, or the logical tags is a mismatch. Nothing else is +/// compared, so agreeing descriptions do **not** establish that these are the region's chunks: no +/// region id, table id, or provenance is part of either side, and two regions whose tags have the +/// same ids, names, and types produce equal descriptions. The check answers "does this frame describe +/// the label layout this binding expects", never "is this the region I asked for". +/// +/// It belongs to the caller that holds both sides: a frame describes its own labels, so +/// [`unpack_raw_series`] decodes it without a binding, and a caller that received the frame from a +/// peer compares the declaration here instead of decoding under whichever side it trusts. +/// +/// The comparison is plain equality. It used to canonicalize logical tag order on both sides, which +/// gave local comparisons one rule and the byte-exact remote comparison another; the writer now +/// canonicalizes instead (see [`LabelDecodeSpec::canonicalize`]), so a description that reaches a +/// reader already has one spelling and equality says everything there is to say about it. +pub fn ensure_frame_matches_label_source( + frame: &ArrowSchema, + label_source: &RawLabelSource, +) -> datafusion_common::Result<()> { + let declared = raw_series_frame_label_decode(frame).map_err(codec_error)?; + if declared != label_decode_spec(label_source) { + return Err(invalid( + "raw frame label decode description does not match the supplied binding", + )); + } + Ok(()) +} + fn label_mode(source: &RawLabelSource) -> RawLabelMode { match source { RawLabelSource::PrimaryKey { .. } => RawLabelMode::Key, @@ -170,6 +337,15 @@ fn label_mode(source: &RawLabelSource) -> RawLabelMode { } } +/// Returns the label mode a frame declares: a key frame describes its key, an explicit frame +/// carries complete labels and declares no description. +fn declared_label_mode(declared: &Option) -> RawLabelMode { + match declared { + Some(_) => RawLabelMode::Key, + None => RawLabelMode::Explicit, + } +} + /// Rejects orderings this adapter cannot validate; a bad declaration is an error, not a fallback. fn ensure_supported_ordering(ordering: SeriesOrdering) -> datafusion_common::Result<()> { match ordering { @@ -408,7 +584,8 @@ struct UnpackStream { source: Option, schema: Arc, frame_schema: datatypes::arrow::datatypes::SchemaRef, - label_source: RawLabelSource, + /// The description parsed from the frame schema; the only input decoding needs. + label_decode_spec: Option, ordering: SeriesOrdering, validator: SequenceValidator, pending: std::vec::IntoIter, @@ -420,8 +597,18 @@ impl RawSeriesStream for UnpackStream { &self.schema } + /// Returns [`NO_BOUND_LABEL_SOURCE`]: a frame establishes its own label layout and carries no + /// scan-bound identity, so decoding reads [`Self::label_decode_spec`] and never this. There is + /// no physical region key layout to report and none is invented from the description, which + /// deliberately carries no region metadata. fn label_source(&self) -> &RawLabelSource { - &self.label_source + &NO_BOUND_LABEL_SOURCE + } + + /// Returns the label decode description the frame declared, which is what decoding reads; an + /// explicit-label frame declares none. + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.label_decode_spec.as_ref() } fn ordering(&self) -> SeriesOrdering { diff --git a/src/common/query/src/series_frame/record_batches/tests.rs b/src/common/query/src/series_frame/record_batches/tests.rs index b24ac5b9431..7d4948e7d18 100644 --- a/src/common/query/src/series_frame/record_batches/tests.rs +++ b/src/common/query/src/series_frame/record_batches/tests.rs @@ -1130,13 +1130,7 @@ async fn exact_int64_filter_sort_adapter_frame_pipeline() { .into(), eval_grid: None, }); - let mut decoded = super::super::unpack_raw_series( - frames, - expected, - store_api::region_engine::RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut decoded = super::super::unpack_raw_series(frames, Some(expected)).unwrap(); let chunk = decoded.next().await.unwrap().unwrap(); assert!(chunk.is_last); assert_eq!(chunk.time.as_buffer().as_ref(), &[1, 2]); diff --git a/src/common/query/src/series_frame/tests.rs b/src/common/query/src/series_frame/tests.rs index feabbe9d019..e306fa98ddf 100644 --- a/src/common/query/src/series_frame/tests.rs +++ b/src/common/query/src/series_frame/tests.rs @@ -28,13 +28,13 @@ use datafusion::physical_plan::RecordBatchStream; use datatypes::arrow::array::{Array, Float64Array, UInt32Array}; use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; use datatypes::arrow::compute::concat_batches; -use datatypes::arrow::datatypes::{DataType, SchemaRef}; +use datatypes::arrow::datatypes::{DataType, Schema, SchemaRef}; use datatypes::arrow::ipc::{reader::StreamReader, writer::StreamWriter}; use datatypes::arrow::record_batch::RecordBatch; use datatypes::series::Label; use datatypes::series::MetricName; use datatypes::series::SeriesOrdering; -use datatypes::series::frame::{RawLabelMode, raw_series_frame_schema}; +use datatypes::series::frame::{LabelDecodeSpec, RawLabelMode, raw_series_frame_schema}; use datatypes::series::histogram::{BucketSideView, CounterResetHint, HistogramBatchBuilder}; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{ @@ -45,7 +45,10 @@ use store_api::region_engine::{ BoundTag, RawLabelSource, RawSeriesStream, SendableRawSeriesStream, }; -use super::{pack_raw_series, raw_series_frame_ordering, unpack_raw_series}; +use super::{ + ensure_frame_matches_label_source, label_decode_spec, pack_raw_series, + raw_series_frame_ordering, unpack_raw_series, +}; fn schema() -> Arc { Arc::new(SeriesSchema { @@ -101,6 +104,8 @@ struct RawMock { schema: Arc, items: VecDeque>, binding: RawLabelSource, + /// The description this fixture declares, derived once from `binding`. + label_decode_spec: Option, ordering: SeriesOrdering, polls: Arc, drops: Arc, @@ -144,6 +149,9 @@ impl RawSeriesStream for RawMock { fn label_source(&self) -> &RawLabelSource { &self.binding } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.label_decode_spec.as_ref() + } fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -239,6 +247,7 @@ fn raw_never_polled( schema: schema(), items: VecDeque::new(), binding: RawLabelSource::Explicit, + label_decode_spec: None, ordering, polls: polls.clone(), drops: drops.clone(), @@ -260,11 +269,15 @@ fn raw_items( ) -> (SendableRawSeriesStream, Arc, Arc) { let polls = Arc::new(AtomicUsize::new(0)); let drops = Arc::new(AtomicUsize::new(0)); + // A fixture holding a key binding declares the layout it encodes, like a real producer; a + // fixture with explicit labels already carries complete ones and declares none. + let label_decode_spec = label_decode_spec(&binding); ( Box::pin(RawMock { schema, items: items.into(), binding, + label_decode_spec, ordering, polls: polls.clone(), drops: drops.clone(), @@ -382,13 +395,7 @@ async fn contiguous_adapter_does_not_certify_nonadjacent_recurrence() { assert_eq!(batches[0].num_rows(), 3); let frame_schema = batches[0].schema(); let (source, _, _) = df_source(batches, frame_schema); - let mut decoded = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut decoded = unpack_raw_series(source, Some(schema())).unwrap(); for (key, time, value) in expected { let actual = decoded.next().await.unwrap().unwrap(); @@ -484,13 +491,7 @@ async fn roundtrip_explicit_unordered_contiguous_preserves_complete_chunk_conten panic_on_poll: false, emitted: 0, }); - let mut output = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut output = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &output.next().await.unwrap().unwrap(), b"complete-b", @@ -535,13 +536,7 @@ async fn key_then_time_valid_roundtrip() { panic_on_poll: false, emitted: 0, }); - let mut out = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .unwrap(); + let mut out = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &out.next().await.unwrap().unwrap(), b"a", @@ -667,10 +662,20 @@ async fn invalid_sequence_reports_valid_prefix_then_one_terminal_error() { .unwrap(), ); } - let frame_schema = frames[0].schema(); + // The ordering is a property of the frame, so the frames must declare the case's one for + // unpack to validate the same sequence the producer rejected. + let frame_schema = super::raw_series_frame_schema_with_ordering( + &schema(), + &RawLabelSource::Explicit, + case.order, + ) + .unwrap(); + let frames = frames + .into_iter() + .map(|frame| frame.with_schema(frame_schema.clone()).unwrap()) + .collect(); let (source, _, _) = df_source(frames, frame_schema); - let mut decoded = - unpack_raw_series(source, schema(), RawLabelSource::Explicit, case.order).unwrap(); + let mut decoded = unpack_raw_series(source, Some(schema())).unwrap(); for (key, time, last, host) in &case.prefix { let chunk = decoded.next().await.unwrap().unwrap(); assert_chunk(&chunk, key, &[*time], &[Some(*time as f64)], *last); @@ -693,13 +698,7 @@ async fn invalid_sequence_reports_valid_prefix_then_one_terminal_error() { .unwrap(); let frame_schema = frame[0].schema(); let (source, _, _) = df_source(frame, frame_schema); - let mut decoded = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut decoded = unpack_raw_series(source, Some(schema())).unwrap(); let first = decoded.next().await.unwrap().unwrap(); assert_chunk(&first, b"unfinished", &[7], &[Some(7.0)], false); assert_eq!( @@ -750,8 +749,11 @@ fn primary_binding(metadata: store_api::metadata::RegionMetadataRef) -> RawLabel } } +/// A key frame states its whole label layout, so unpacking keeps exactly that description and no +/// binding: the description is what a consumer decodes from, and the physical region metadata the +/// producer read under never reaches the receiver. #[tokio::test] -async fn primary_key_binding_is_retained_exactly() { +async fn the_declared_description_is_retained_and_no_binding_is_invented() { use api::v1::SemanticType; use datatypes::schema::ColumnSchema; use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder}; @@ -779,6 +781,7 @@ async fn primary_key_binding_is_retained_exactly() { .primary_key(vec![0]); let metadata = Arc::new(builder.build().unwrap()); let binding = primary_binding(metadata.clone()); + let description = label_decode_spec(&binding).expect("a primary-key binding describes its key"); let (input, _, _) = raw_custom( vec![ RawSeriesChunk::try_new( @@ -806,25 +809,25 @@ async fn primary_key_binding_is_retained_exactly() { panic_on_poll: false, emitted: 0, }); - let mut out = unpack_raw_series(source, schema(), binding, SeriesOrdering::Contiguous).unwrap(); + let mut out = unpack_raw_series(source, None).unwrap(); let decoded = out.next().await.unwrap().unwrap(); assert_eq!(decoded.primary_key.as_slice(), b"encoded-complete-key"); assert!(decoded.metric.is_none()); - let retained = out.label_source(); - match retained { - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - logical_tags, - } => { - assert!(Arc::ptr_eq(physical_metadata, &metadata)); - assert_eq!(metric_name.as_ref(), "cpu"); - assert_eq!(logical_tags.as_ref().len(), 1); - assert_eq!(logical_tags[0].column_id, 0); - assert_eq!(logical_tags[0].name.as_ref(), "host"); - } - _ => panic!("binding changed"), - } + assert_eq!( + out.label_decode_spec(), + Some(&description), + "the declaration is retained exactly as the frame states it" + ); + assert_eq!(description.metric_name, "cpu"); + assert_eq!(description.primary_key.len(), 1); + assert_eq!(description.primary_key[0].column_id, 0); + assert_eq!(description.primary_key[0].name, "host"); + assert_eq!(description.logical_tags.len(), 1); + assert_eq!(description.logical_tags[0].name, "host"); + assert!( + matches!(out.label_source(), RawLabelSource::Explicit), + "the physical region metadata the producer read under is not carried across the frame" + ); } #[tokio::test] @@ -946,6 +949,7 @@ async fn locally_invalid_public_raw_chunk_is_rejected() { schema: schema(), items: VecDeque::from([Ok(bad)]), binding: RawLabelSource::Explicit, + label_decode_spec: None, ordering: SeriesOrdering::Contiguous, polls, drops: Arc::new(AtomicUsize::new(0)), @@ -977,13 +981,7 @@ async fn empty_streams_keep_self_describing_schema() { // A declared-but-empty stream is still validated against the expected schema before any // poll, and then ends without producing rows. let (source, polls, _) = df_source(vec![], frame_schema.clone()); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); assert_eq!(polls.load(Ordering::SeqCst), 0); assert_eq!(unpacked.schema().fields.len(), 1); assert_eq!(unpacked.schema().fields[0].name.as_ref(), "value"); @@ -1000,6 +998,7 @@ async fn upstream_error_status_is_preserved_once_and_releases_source() { schema: schema(), items: VecDeque::from([Err(e)]), binding: RawLabelSource::Explicit, + label_decode_spec: None, ordering: SeriesOrdering::Contiguous, polls: polls.clone(), drops: drops.clone(), @@ -1037,13 +1036,7 @@ async fn upstream_error_status_is_preserved_once_and_releases_source() { panic_on_poll: false, emitted: 0, }); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let error = unpacked.next().await.unwrap().unwrap_err(); assert_eq!(common_error::ext::ErrorExt::status_code(&error), status); assert!(unpacked.next().await.is_none()); @@ -1096,15 +1089,7 @@ async fn dropping_adapter_releases_upstream_and_pending_item_is_resumed() { async fn unpack_rejects_mismatched_batch_schema_before_polling() { let wrong = datatypes::arrow::datatypes::Schema::empty().into(); let (source, polls, drops) = df_source(vec![], wrong); - assert!( - unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous - ) - .is_err() - ); + assert!(unpack_raw_series(source, Some(schema())).is_err()); assert_eq!(polls.load(Ordering::SeqCst), 0); assert_eq!(drops.load(Ordering::SeqCst), 1); } @@ -1123,6 +1108,7 @@ async fn invalid_grid_schema_rejected_for_empty_raw_stream() { schema: Arc::new(invalid), items: VecDeque::new(), binding: RawLabelSource::Explicit, + label_decode_spec: None, ordering: SeriesOrdering::Contiguous, polls: polls.clone(), drops: Arc::new(AtomicUsize::new(0)), @@ -1138,9 +1124,11 @@ async fn invalid_grid_schema_rejected_for_empty_raw_stream() { #[tokio::test] async fn unpack_batch_sequence_error_is_atomic_for_all_rows() { let mut batches = Vec::new(); - let frame_schema = datatypes::series::frame::raw_series_frame_schema( + // `KeyThenTime` is what makes a recurring key an error, so the frame has to declare it. + let frame_schema = super::raw_series_frame_schema_with_ordering( &schema(), - datatypes::series::frame::RawLabelMode::Explicit, + &RawLabelSource::Explicit, + SeriesOrdering::KeyThenTime, ) .unwrap(); for (key, time) in [ @@ -1153,13 +1141,7 @@ async fn unpack_batch_sequence_error_is_atomic_for_all_rows() { } let multi = concat_batches(&frame_schema, &batches).unwrap(); let (source, _, _) = df_source(vec![multi], frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); assert!(unpacked.next().await.unwrap().is_err()); assert!(unpacked.next().await.is_none()); } @@ -1216,13 +1198,7 @@ async fn unpack_rejects_late_invalid_frame_row_before_prefix() { second = RecordBatch::try_new(frame_schema.clone(), columns).unwrap(); let multi = concat_batches(&frame_schema, &[first, second]).unwrap(); let (source, _, _) = df_source(vec![multi], frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let error = unpacked.next().await.unwrap().unwrap_err(); assert_eq!( common_error::ext::ErrorExt::status_code(&error), @@ -1264,13 +1240,7 @@ async fn frame_application_metadata_is_stable_and_schema_change_rejected() { .with_schema(declared.clone()) .unwrap(); let (source, _, _) = df_source(vec![first.clone(), stable_second], declared.clone()); - let mut stable = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut stable = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &stable.next().await.unwrap().unwrap(), b"a", @@ -1305,13 +1275,7 @@ async fn frame_application_metadata_is_stable_and_schema_change_rejected() { .with_schema(changed_schema) .unwrap(); let (source, _, _) = df_source(vec![first.clone(), bad_batch], declared.clone()); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &unpacked.next().await.unwrap().unwrap(), b"a", @@ -1367,13 +1331,7 @@ async fn unpack_bounds_empty_batches_per_poll_and_eventually_yields_row() { let mut batches = vec![RecordBatch::new_empty(frame_schema.clone()); 10_000]; batches.push(row); let (source, polls, _) = df_source(batches, frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let wake = Arc::new(CountingWake(AtomicUsize::new(0))); let waker = Waker::from(wake.clone()); @@ -1427,13 +1385,7 @@ fn unpack_yields_at_empty_batch_budget_before_observing_eof() { .unwrap(); let batches = vec![RecordBatch::new_empty(frame_schema.clone()); BUDGET]; let (source, polls, _) = df_source(batches, frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let wake = Arc::new(CountingWake(AtomicUsize::new(0))); let waker = Waker::from(wake.clone()); @@ -1462,13 +1414,7 @@ fn unpack_propagates_upstream_pending_without_extra_wake() { frame_schema, 1, ); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let wake = Arc::new(CountingWake(AtomicUsize::new(0))); let waker = Waker::from(wake.clone()); @@ -1493,13 +1439,7 @@ fn unpack_reports_bad_batch_at_budget_boundary_before_yielding() { datatypes::arrow::datatypes::Schema::empty().into(), )); let (source, polls, _) = df_source(batches, frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let wake = Arc::new(CountingWake(AtomicUsize::new(0))); let waker = Waker::from(wake.clone()); @@ -1541,13 +1481,7 @@ fn unpack_surfaces_upstream_error_after_budget_yield_once() { panic_on_poll: false, emitted: 0, }); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); let wake = Arc::new(CountingWake(AtomicUsize::new(0))); let waker = Waker::from(wake.clone()); @@ -1579,13 +1513,7 @@ async fn unpack_drains_decoded_rows_before_polling_another_batch() { let frame_schema = batches[0].schema(); let multi = concat_batches(&frame_schema, &batches).unwrap(); let (source, polls, _) = df_source(vec![multi], frame_schema); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &unpacked.next().await.unwrap().unwrap(), b"a", @@ -1633,15 +1561,7 @@ async fn unpack_preflights_empty_and_grid_schemas_without_polling() { ) .unwrap(); let (source, polls, drops) = df_source(vec![], frame_schema); - assert!( - unpack_raw_series( - source, - Arc::new(expected), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .is_err() - ); + assert!(unpack_raw_series(source, Some(Arc::new(expected))).is_err()); assert_eq!(polls.load(Ordering::SeqCst), 0); assert_eq!(drops.load(Ordering::SeqCst), 1); } @@ -1683,6 +1603,7 @@ async fn named_frame_fields_roundtrip_and_reject_wrong_bindings_before_polling() schema: expected.clone(), items: VecDeque::from([Ok(input_chunk)]), binding: RawLabelSource::Explicit, + label_decode_spec: None, ordering: SeriesOrdering::KeyThenTime, polls: Arc::new(AtomicUsize::new(0)), drops: Arc::new(AtomicUsize::new(0)), @@ -1716,13 +1637,7 @@ async fn named_frame_fields_roundtrip_and_reject_wrong_bindings_before_polling() ); let (source, _, _) = df_source(ipc_batches.clone(), frame_schema.clone()); - let mut decoded = unpack_raw_series( - source, - expected.clone(), - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .unwrap(); + let mut decoded = unpack_raw_series(source, Some(expected.clone())).unwrap(); let actual = decoded.next().await.unwrap().unwrap(); assert_chunk( &actual, @@ -1750,15 +1665,7 @@ async fn named_frame_fields_roundtrip_and_reject_wrong_bindings_before_polling() let wrong_schema = named_fields_schema(wrong_names); let batches = if empty { vec![] } else { ipc_batches.clone() }; let (source, polls, drops) = df_source(batches, frame_schema.clone()); - assert!( - unpack_raw_series( - source, - wrong_schema, - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .is_err() - ); + assert!(unpack_raw_series(source, Some(wrong_schema)).is_err()); assert_eq!(polls.load(Ordering::SeqCst), 0); assert_eq!(drops.load(Ordering::SeqCst), 1); } @@ -2193,13 +2100,7 @@ async fn multirow_ipc_roundtrip_preserves_cross_chunk_variable_segments() { let frame_schema = reader.schema(); let ipc_batches = reader.map(Result::unwrap).collect::>(); let (source, _, _) = df_source(ipc_batches, frame_schema); - let mut decoded = unpack_raw_series( - source, - schema.clone(), - RawLabelSource::Explicit, - SeriesOrdering::KeyThenTime, - ) - .unwrap(); + let mut decoded = unpack_raw_series(source, Some(schema.clone())).unwrap(); struct Expected { time: i64, @@ -2391,7 +2292,7 @@ async fn interleaved_ordering_is_rejected_before_any_poll() { assert!( super::raw_series_frame_schema_with_ordering( &schema(), - datatypes::series::frame::RawLabelMode::Explicit, + &RawLabelSource::Explicit, SeriesOrdering::Interleaved, ) .is_err(), @@ -2414,20 +2315,27 @@ async fn interleaved_ordering_is_rejected_before_any_poll() { ); assert_eq!(drops.load(Ordering::SeqCst), 1); - let frame_schema = datatypes::series::frame::raw_series_frame_schema( + // An unpack has no ordering argument, so the only way an `Interleaved` stream could reach it is + // a frame that declares one; that spelling has no wire representation and is rejected. + let frame = datatypes::series::frame::raw_series_frame_schema( &schema(), datatypes::series::frame::RawLabelMode::Explicit, ) .unwrap(); - let (source, polls, drops) = df_never_polled(frame_schema); - assert!( - unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Interleaved, - ) - .is_err() + let mut metadata = frame.metadata().clone(); + metadata.insert( + "greptime.series.ordering".to_string(), + "interleaved".to_string(), + ); + let frame = Arc::new(Schema::new_with_metadata(frame.fields().clone(), metadata)); + let (source, polls, drops) = df_never_polled(frame); + let error = match unpack_raw_series(source, Some(schema())) { + Ok(_) => panic!("an interleaved declaration must be rejected"), + Err(error) => error, + }; + assert_eq!( + crate::error::datafusion_status_code::(&error, None), + StatusCode::InvalidArguments ); assert_eq!(polls.load(Ordering::SeqCst), 0); assert_eq!(drops.load(Ordering::SeqCst), 1); @@ -2482,13 +2390,7 @@ async fn unpack_rejects_unrelated_metadata_drift_and_empty_schema_changes() { RecordBatch::try_new(changed, first.columns().to_vec()).unwrap() }; let (source, _, _) = df_source(vec![first.clone(), bad], base.clone()); - let mut unpacked = unpack_raw_series( - source, - schema(), - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut unpacked = unpack_raw_series(source, Some(schema())).unwrap(); assert_chunk( &unpacked.next().await.unwrap().unwrap(), b"a", @@ -2609,8 +2511,7 @@ async fn pack_declares_ordering_on_every_batch_and_ipc_roundtrips_it() { assert_eq!(ipc_batches.len(), expected_batch_count); assert!(ipc_batches.iter().all(|batch| batch.schema() == declared)); let (source, _, _) = df_source(ipc_batches, declared); - let mut decoded = - unpack_raw_series(source, schema(), RawLabelSource::Explicit, ordering).unwrap(); + let mut decoded = unpack_raw_series(source, Some(schema())).unwrap(); if is_empty { assert!(decoded.next().await.is_none()); } else { @@ -2628,3 +2529,552 @@ async fn pack_declares_ordering_on_every_batch_and_ipc_roundtrips_it() { } } } + +/// A primary-key binding over a region whose only tag is `host`. +fn key_binding(metric: &str) -> RawLabelSource { + use api::v1::SemanticType; + use datatypes::schema::ColumnSchema; + use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder}; + use store_api::storage::RegionId; + let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 1)); + builder + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "host", + datatypes::data_type::ConcreteDataType::string_datatype(), + false, + ), + semantic_type: SemanticType::Tag, + column_id: 0, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "ts", + datatypes::data_type::ConcreteDataType::timestamp_millisecond_datatype(), + false, + ), + semantic_type: SemanticType::Timestamp, + column_id: 1, + }) + .primary_key(vec![0]); + let metadata = Arc::new(builder.build().unwrap()); + RawLabelSource::PrimaryKey { + metric_name: Arc::from(metric), + physical_metadata: metadata, + logical_tags: Arc::from([BoundTag { + column_id: 0, + name: Arc::from("host"), + }]), + } +} + +/// A primary-key binding over a region whose two tags are `host` then `zone`. +fn two_tag_binding(metric: &str) -> RawLabelSource { + use api::v1::SemanticType; + use datatypes::schema::ColumnSchema; + use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder}; + use store_api::storage::RegionId; + let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 1)); + builder + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "host", + datatypes::data_type::ConcreteDataType::string_datatype(), + false, + ), + semantic_type: SemanticType::Tag, + column_id: 0, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "zone", + datatypes::data_type::ConcreteDataType::string_datatype(), + false, + ), + semantic_type: SemanticType::Tag, + column_id: 1, + }) + .push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "ts", + datatypes::data_type::ConcreteDataType::timestamp_millisecond_datatype(), + false, + ), + semantic_type: SemanticType::Timestamp, + column_id: 2, + }) + .primary_key(vec![0, 1]); + let metadata = Arc::new(builder.build().unwrap()); + RawLabelSource::PrimaryKey { + metric_name: Arc::from(metric), + physical_metadata: metadata, + logical_tags: Arc::from([ + BoundTag { + column_id: 0, + name: Arc::from("host"), + }, + BoundTag { + column_id: 1, + name: Arc::from("zone"), + }, + ]), + } +} + +/// A key frame whose metadata carries exactly the supplied description and nothing else. +fn key_frame_with(description: &datatypes::series::frame::LabelDecodeSpec) -> SchemaRef { + let layout = raw_series_frame_schema(&schema(), RawLabelMode::Key).unwrap(); + let mut metadata = layout.metadata().clone(); + metadata.insert( + "greptime.series.label_decode".to_string(), + serde_json::to_string(description).unwrap(), + ); + Arc::new(Schema::new_with_metadata(layout.fields().clone(), metadata)) +} + +/// Logical tag order is fixed by the writer, so the wire form has one spelling and the comparison +/// is plain equality. The ordered key columns are the encoding, so the same columns in another +/// order are a different layout and are rejected. +/// +/// The tag order is canonical rather than meaningful: the decoder sorts tags by name when it binds +/// them (`src/mito-codec/src/labels.rs:235`), so sorting in the writer cannot change a decoded label +/// set. What it does change is that the written text no longer depends on the caller's order, which +/// is what makes one comparison rule enough for both a local check and the byte-exact remote one. +#[tokio::test] +async fn frame_check_compares_the_canonical_description_and_key_order_exactly() { + let binding = two_tag_binding("cpu"); + let description = label_decode_spec(&binding).expect("a primary-key binding describes its key"); + assert_eq!( + description.logical_tags.len(), + 2, + "two tags to canonicalize" + ); + + // A description derived from the binding is already canonical, whichever order the binding + // lists its tags in. + let RawLabelSource::PrimaryKey { + metric_name, + physical_metadata, + logical_tags, + } = &binding + else { + unreachable!("the fixture is a primary-key binding") + }; + let mut reversed_tags = logical_tags.to_vec(); + reversed_tags.reverse(); + let reordered_binding = RawLabelSource::PrimaryKey { + metric_name: metric_name.clone(), + physical_metadata: physical_metadata.clone(), + logical_tags: reversed_tags.into(), + }; + assert_eq!( + label_decode_spec(&reordered_binding).expect("a primary-key binding describes its key"), + description, + "a binding's tag order does not reach the description" + ); + let reordered = + label_decode_spec(&reordered_binding).expect("a primary-key binding describes its key"); + assert_eq!( + key_frame_with(&reordered).metadata(), + key_frame_with(&description).metadata(), + "and so two producers with the same tags write the same bytes" + ); + + // A frame that is not canonical is compared as written instead of being guessed equal. + let mut non_canonical = description.clone(); + non_canonical.logical_tags.reverse(); + let frame = key_frame_with(&non_canonical); + assert!( + ensure_frame_matches_label_source(frame.as_ref(), &binding).is_err(), + "the reader does not reorder what a writer should have ordered" + ); + + // The ordered key columns are the encoding and are never reordered. + let mut reordered_key = description; + reordered_key.primary_key.reverse(); + let frame = key_frame_with(&reordered_key); + assert!( + ensure_frame_matches_label_source(frame.as_ref(), &binding).is_err(), + "primary-key column order is the encoding and is compared as written" + ); +} + +/// The identity check a caller performs on its own compares the frame's declaration with the +/// binding it holds: a different metric, another key layout, or an explicit frame with no +/// declaration at all is a disagreement, and the frame alone still decides what is decoded. +#[tokio::test] +async fn frame_check_rejects_a_binding_the_frame_does_not_describe() { + let description = + label_decode_spec(&key_binding("cpu")).expect("a primary-key binding describes its key"); + let frame = key_frame_with(&description); + assert!( + ensure_frame_matches_label_source(frame.as_ref(), &key_binding("cpu")).is_ok(), + "the same binding is the same layout" + ); + assert!( + ensure_frame_matches_label_source(frame.as_ref(), &key_binding("other_metric")).is_err(), + "another metric is another label layout" + ); + assert!( + ensure_frame_matches_label_source(frame.as_ref(), &RawLabelSource::Explicit).is_err(), + "an explicit identity expects a frame that carries complete labels" + ); + + let explicit = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + assert!( + ensure_frame_matches_label_source(explicit.as_ref(), &key_binding("cpu")).is_err(), + "a frame declaring no layout cannot satisfy a primary-key identity" + ); + assert!( + ensure_frame_matches_label_source(explicit.as_ref(), &RawLabelSource::Explicit).is_ok(), + "an explicit frame is what an explicit identity expects" + ); +} + +/// The description parsed at unpack time is retained on the returned stream, so a consumer that +/// holds only the frame can decode; an explicit frame retains none. +#[tokio::test] +async fn unpack_retains_the_declared_description_without_a_binding() { + let binding = key_binding("cpu"); + let description = label_decode_spec(&binding).expect("a primary-key binding describes its key"); + + let (source, _, _) = df_source(vec![], key_frame_with(&description)); + let unpacked = unpack_raw_series(source, None).unwrap(); + assert_eq!(unpacked.label_decode_spec(), Some(&description)); + assert!( + matches!(unpacked.label_source(), RawLabelSource::Explicit), + "no physical region key layout is invented for a frame that carries none" + ); + + let explicit = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + let (source, _, _) = df_source(vec![], explicit); + let unpacked = unpack_raw_series(source, None).unwrap(); + assert_eq!( + unpacked.label_decode_spec(), + None, + "explicit labels are already complete and need no decoder" + ); +} + +/// A key frame whose `greptime.series.label_decode` entry is exactly the supplied text. +fn key_frame_with_encoded(encoded: &str) -> SchemaRef { + let layout = raw_series_frame_schema(&schema(), RawLabelMode::Key).unwrap(); + let mut metadata = layout.metadata().clone(); + metadata.insert( + "greptime.series.label_decode".to_string(), + encoded.to_string(), + ); + Arc::new(Schema::new_with_metadata(layout.fields().clone(), metadata)) +} + +/// The description is validated whether or not a binding is supplied, so trusting the frame never +/// means skipping validation: an unknown version, an unknown field, a malformed entry, a logical +/// tag outside the key, a key frame without a description, and an explicit frame carrying one are +/// all rejected before the stream is returned. +#[tokio::test] +async fn unpack_rejects_invalid_descriptions_without_a_binding() { + let description = + label_decode_spec(&key_binding("cpu")).expect("a primary-key binding describes its key"); + let encoded = |value: &serde_json::Value| serde_json::to_string(value).unwrap(); + let mut unknown_version = serde_json::to_value(&description).unwrap(); + unknown_version["version"] = + serde_json::json!(datatypes::series::frame::LABEL_DECODE_VERSION + 1); + let mut unknown_field = serde_json::to_value(&description).unwrap(); + unknown_field["future_field"] = serde_json::json!(1); + let mut unlisted_tag = serde_json::to_value(&description).unwrap(); + unlisted_tag["logical_tags"] = serde_json::json!([{ "column_id": 7, "name": "absent" }]); + let cases = [ + ("unknown version", encoded(&unknown_version)), + ("unknown field", encoded(&unknown_field)), + ( + "logical tag outside the primary key", + encoded(&unlisted_tag), + ), + ("malformed entry", "{".to_string()), + ]; + for (label, text) in cases { + let (source, polls, _) = df_source(vec![], key_frame_with_encoded(&text)); + let error = match unpack_raw_series(source, None) { + Ok(_) => panic!("{label}: an invalid description must be rejected"), + Err(error) => error, + }; + assert_eq!( + crate::error::datafusion_status_code::(&error, None), + StatusCode::InvalidArguments, + "{label}" + ); + assert_eq!( + polls.load(Ordering::SeqCst), + 0, + "{label}: a rejected frame is never polled" + ); + } + + let key_without_description = raw_series_frame_schema(&schema(), RawLabelMode::Key).unwrap(); + let (source, _, _) = df_source(vec![], key_without_description); + assert!( + unpack_raw_series(source, None).is_err(), + "a key frame nobody can decode is rejected instead of being trusted" + ); + + let explicit = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + let mut metadata = explicit.metadata().clone(); + metadata.insert( + "greptime.series.label_decode".to_string(), + encoded(&serde_json::to_value(&description).unwrap()), + ); + let explicit_with_description = Arc::new(Schema::new_with_metadata( + explicit.fields().clone(), + metadata, + )); + let (source, _, _) = df_source(vec![], explicit_with_description); + assert!( + unpack_raw_series(source, None).is_err(), + "an explicit frame declares no description, so one is a contradiction" + ); +} + +fn key_chunk() -> RawSeriesChunk { + chunk_with(b"encoded-complete-key", &[1], vec![Some(3.0)], None, true) +} + +/// Every packed key frame declares the label decode description derived from its binding, so a +/// consumer needs no scan-side label source. Explicit frames declare none. +#[tokio::test] +async fn pack_declares_the_label_decode_description_of_its_binding() { + let binding = key_binding("cpu"); + let (input, _, _) = raw_custom( + vec![key_chunk()], + SeriesOrdering::Contiguous, + binding.clone(), + ); + let mut packed = pack_raw_series(input).unwrap(); + let declared = packed.schema(); + let canonical = raw_series_frame_schema(&schema(), RawLabelMode::Key).unwrap(); + assert_eq!( + declared.metadata().len(), + canonical.metadata().len() + 2, + "a key frame adds the producer ordering and the label decode description" + ); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(&declared).unwrap(), + label_decode_spec(&binding), + "the declared description is derived from the binding, not from the first row" + ); + let batch = packed.next().await.unwrap().unwrap(); + assert_eq!(batch.schema(), declared); + + let (input, _, _) = raw_custom( + vec![key_chunk()], + SeriesOrdering::Contiguous, + RawLabelSource::Explicit, + ); + let packed = pack_raw_series(input).unwrap(); + let declared = packed.schema(); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(&declared).unwrap(), + None, + "explicit labels are already complete" + ); + assert_eq!(declared.metadata().len(), canonical.metadata().len() + 1); +} + +/// A key frame that omits its description is not decodable, and unpacking says so instead of falling +/// back to a caller-side layout; the description that is present survives the round trip. +#[tokio::test] +async fn unpack_round_trips_a_key_frame_and_rejects_one_without_a_description() { + let binding = key_binding("cpu"); + let (input, _, _) = raw_custom( + vec![key_chunk()], + SeriesOrdering::Contiguous, + binding.clone(), + ); + let mut packed = pack_raw_series(input).unwrap(); + let declared = packed.schema(); + let batch = packed.next().await.unwrap().unwrap(); + + // The frame and the binding agree, so the round trip decodes. + let (source, _, _) = df_source(vec![batch.clone()], declared.clone()); + let mut decoded = unpack_raw_series(source, Some(schema())).unwrap(); + let chunk = decoded.next().await.unwrap().unwrap(); + assert_eq!(chunk.primary_key.as_slice(), b"encoded-complete-key"); + + // A frame that omits the description is not decodable from the schema alone. + let canonical = raw_series_frame_schema(&schema(), RawLabelMode::Key).unwrap(); + let mut metadata = canonical.metadata().clone(); + metadata.insert( + "greptime.series.ordering".to_string(), + "contiguous".to_string(), + ); + let stripped = Arc::new(datatypes::arrow::datatypes::Schema::new_with_metadata( + declared.fields().clone(), + metadata, + )); + let stripped_batch = RecordBatch::try_new(stripped.clone(), batch.columns().to_vec()).unwrap(); + let (source, _, _) = df_source(vec![stripped_batch], stripped); + let error = match unpack_raw_series(source, Some(schema())) { + Ok(_) => panic!("a key frame without a description must be rejected"), + Err(error) => error, + }; + assert_eq!( + crate::error::datafusion_status_code::(&error, None), + StatusCode::InvalidArguments + ); +} + +/// The ordering a frame declares is the only ordering a consumer has: unpacking a `KeyThenTime` +/// frame reports `KeyThenTime` although nobody passed it in. +#[tokio::test] +async fn unpack_reports_the_ordering_the_frame_declares() { + let (input, _, _) = raw_custom( + vec![key_chunk()], + SeriesOrdering::KeyThenTime, + key_binding("cpu"), + ); + let packed = pack_raw_series(input).unwrap(); + assert_eq!( + raw_series_frame_ordering(packed.schema().as_ref()).unwrap(), + SeriesOrdering::KeyThenTime, + "the producer declaration reaches the wire" + ); + + let unpacked = unpack_raw_series(packed, None).unwrap(); + assert_eq!( + unpacked.ordering(), + SeriesOrdering::KeyThenTime, + "unpacking derives the ordering from the frame, not from a caller" + ); + + // A frame that declares no ordering is a legacy contiguous frame. + let frame = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + let (source, _, _) = df_source(vec![], frame); + assert_eq!( + unpack_raw_series(source, None).unwrap().ordering(), + SeriesOrdering::Contiguous + ); +} + +/// Reserved layout metadata this revision does not write is rejected instead of being read as the +/// current layout, so a frame cannot be decoded under a contract it did not declare. +#[tokio::test] +async fn unpack_rejects_unknown_layout_metadata() { + let cases = [ + ("greptime.series.layout", "v2"), + ("greptime.series.content", "series"), + ("greptime.series.time", "timestamps_v2"), + ("greptime.series.ordering", "hash_then_time"), + ]; + for (key, value) in cases { + let frame = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + let mut metadata = frame.metadata().clone(); + metadata.insert(key.to_string(), value.to_string()); + let frame = Arc::new(Schema::new_with_metadata(frame.fields().clone(), metadata)); + let (source, polls, _) = df_source(vec![], frame); + assert!( + unpack_raw_series(source, None).is_err(), + "{key} = {value} is not a declaration this revision reads" + ); + assert_eq!(polls.load(Ordering::SeqCst), 0, "{key}"); + } +} + +/// `expected` is a check, not an input: a schema the frame does not declare is rejected, and the +/// same frame unpacks without it because the frame is the only source of decode information. +#[tokio::test] +async fn unpack_rejects_an_expected_schema_the_frame_does_not_declare() { + let frame = raw_series_frame_schema(&schema(), RawLabelMode::Explicit).unwrap(); + + let other_field = Arc::new(SeriesSchema { + fields: vec![FieldSpec { + name: Arc::from("other_value"), + }] + .into(), + eval_grid: None, + }); + let (source, polls, _) = df_source(vec![], frame.clone()); + let error = match unpack_raw_series(source, Some(other_field)) { + Ok(_) => panic!("a field layout the frame does not declare must be rejected"), + Err(error) => error, + }; + assert_eq!( + crate::error::datafusion_status_code::(&error, None), + StatusCode::InvalidArguments + ); + assert_eq!(polls.load(Ordering::SeqCst), 0, "nothing is polled"); + + let two_fields = Arc::new(SeriesSchema { + fields: vec![ + FieldSpec { + name: Arc::from("value"), + }, + FieldSpec { + name: Arc::from("second"), + }, + ] + .into(), + eval_grid: None, + }); + let (source, _, _) = df_source(vec![], frame.clone()); + assert!( + unpack_raw_series(source, Some(two_fields)).is_err(), + "the same names in a longer list are another layout" + ); + + let (source, _, _) = df_source(vec![], frame.clone()); + assert!( + unpack_raw_series(source, Some(schema())).is_ok(), + "the schema the frame declares is accepted" + ); + + let (source, _, _) = df_source(vec![], frame); + let unpacked = unpack_raw_series(source, None).unwrap(); + assert_eq!( + unpacked + .schema() + .fields + .iter() + .map(|field| field.name.as_ref()) + .collect::>(), + vec!["value"], + "the returned schema is the frame's, whether or not an expectation was given" + ); +} + +/// Unpacking a frame and packing it again keeps both declarations, so a relayed stream is the same +/// stream: the description still decodes the same labels and the ordering still holds. +#[tokio::test] +async fn unpack_pack_unpack_preserves_the_description_and_the_ordering() { + let binding = key_binding("cpu"); + let description = label_decode_spec(&binding).expect("a primary-key binding describes its key"); + let (input, _, _) = raw_custom(vec![key_chunk()], SeriesOrdering::KeyThenTime, binding); + let packed = pack_raw_series(input).unwrap(); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(packed.schema().as_ref()).unwrap(), + Some(description.clone()) + ); + + let unpacked = unpack_raw_series(packed, None).unwrap(); + assert_eq!(unpacked.label_decode_spec(), Some(&description)); + assert_eq!(unpacked.ordering(), SeriesOrdering::KeyThenTime); + + let repacked = pack_raw_series(unpacked).unwrap(); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(repacked.schema().as_ref()) + .unwrap(), + Some(description.clone()), + "the repacked frame declares what the unpacked stream declared" + ); + assert_eq!( + raw_series_frame_ordering(repacked.schema().as_ref()).unwrap(), + SeriesOrdering::KeyThenTime, + "the repacked frame keeps the ordering it was unpacked under" + ); + + let mut again = unpack_raw_series(repacked, None).unwrap(); + assert_eq!(again.label_decode_spec(), Some(&description)); + assert_eq!(again.ordering(), SeriesOrdering::KeyThenTime); + let chunk = again.next().await.unwrap().unwrap(); + assert_eq!(chunk.primary_key.as_slice(), b"encoded-complete-key"); + assert!(chunk.metric.is_none(), "key chunks stay undecoded"); + assert!(again.next().await.is_none()); +} diff --git a/src/common/query/src/series_frame/tests/lifecycle.rs b/src/common/query/src/series_frame/tests/lifecycle.rs index b8558c89b03..54f088a5098 100644 --- a/src/common/query/src/series_frame/tests/lifecycle.rs +++ b/src/common/query/src/series_frame/tests/lifecycle.rs @@ -193,13 +193,7 @@ async fn streaming_round_trip_preserves_sliced_multifield_payload_and_owners() { chunk: Some(chunk), }); let frames = pack_raw_series(input).unwrap(); - let mut decoded = unpack_raw_series( - frames, - schema, - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut decoded = unpack_raw_series(frames, Some(schema)).unwrap(); let mut output = Vec::new(); while let Some(item) = decoded.next().await { output.push(item.unwrap()); @@ -425,13 +419,7 @@ async fn single_row_sliced_payload_keeps_zero_copy_buffers_and_releases_owners() chunk: Some(chunk), }); let frames = pack_raw_series(input).unwrap(); - let mut decoded = unpack_raw_series( - frames, - schema, - RawLabelSource::Explicit, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let mut decoded = unpack_raw_series(frames, Some(schema)).unwrap(); let mut output = Vec::new(); while let Some(item) = decoded.next().await { output.push(item.unwrap()); diff --git a/src/datatypes/src/series/frame.rs b/src/datatypes/src/series/frame.rs index da6ff38d1ae..2dcab10523c 100644 --- a/src/datatypes/src/series/frame.rs +++ b/src/datatypes/src/series/frame.rs @@ -18,11 +18,45 @@ //! chunk and [`RawSeriesFrameBuilder`] emits rows directly as Arrow arrays once a row or estimated //! byte bound is reached. Decoding reuses one prepared schema per stream through //! [`RawSeriesFrameDecoder`]. Column layout, metadata, and wire strings stay on the frozen `v1` -//! contract. +//! contract; a key-encoded frame additionally declares a versioned [`LabelDecodeSpec`] so its +//! labels can be decoded from the received schema alone. +//! +//! # Label decode description versioning +//! +//! The description is one `greptime.series.label_decode` metadata entry, and remote plans compare +//! `greptime.series.*` metadata **as exact text, byte for byte**: a frontend and a datanode that +//! write different text for the same labels disagree about the schema, and the query fails instead +//! of falling back. The rules that follow from that are: +//! +//! - A reader rejects a version it does not implement, rather than guessing. This module writes and +//! reads [`LABEL_DECODE_VERSION`] only. +//! - [`LABEL_DECODE_VERSION`] covers the whole text, including each key column's +//! [`LabelDecodeColumn::physical_type`]. That field is a [`ConcreteDataType`], so the text embeds +//! [`ConcreteDataType`]'s serde shape: it is not spelled here, and a change in that type's +//! serialization does alter this text. What keeps such a change from going unnoticed is the +//! frozen fixture `tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json`, +//! which records the text of every datatype variant and fails the crate's tests when any of them +//! is serialized differently. +//! - Any change to the shape of this text — a renamed or added field, a different +//! [`ConcreteDataType`] spelling, a different `logical_tags` order — is a **breaking** change at +//! the same version, because it makes two nodes of different builds disagree. Such a change must +//! bump [`LABEL_DECODE_VERSION`] and update that fixture with it. +//! - The text is canonical: [`LabelDecodeSpec::canonicalize`] fixes the only part whose order +//! carries no meaning, so equal labels always write equal bytes. +//! +//! Deployment rule: during a rolling upgrade a frame may carry a version the receiving node does not +//! implement, and a byte-for-byte schema comparison may fail for that reason alone. The intended +//! behaviour is that such a mismatch disables the native series path at planning time and falls back +//! to the generic path, instead of failing the query at execution. +//! +//! TODO(narthex): implement that planning-time fallback. No planner reads +//! `raw_series_frame_label_decode` to decide support today, so a version or text mismatch currently +//! surfaces as an execution-time error from the raw-frame decode or the remote schema comparison. #[cfg(test)] mod tests; +use std::collections::HashSet; use std::sync::Arc; use arrow::array::{ @@ -33,12 +67,14 @@ use arrow::buffer::{Buffer, NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow::compute::concat; use arrow::datatypes::{DataType, Field, Fields, Schema, SchemaRef}; use arrow::record_batch::{RecordBatch, RecordBatchOptions}; +use serde::{Deserialize, Serialize}; +use crate::data_type::ConcreteDataType; use crate::error::{InvalidSeriesSnafu, Result}; use crate::series::histogram::{HistogramBatch, fields as histogram_fields}; use crate::series::raw::{RawSeriesChunk, RawTimestamps}; use crate::series::samples::{ - DenseSamplesRef, HistogramSamples, Samples, SeriesFields, SeriesSchema, + DenseSamplesRef, FieldSpec, HistogramSamples, Samples, SeriesFields, SeriesSchema, }; use crate::series::{Label, MetricName, invalid_series}; @@ -46,6 +82,12 @@ const LAYOUT: &str = "greptime.series.layout"; const CONTENT: &str = "greptime.series.content"; const LABELS: &str = "greptime.series.labels"; const TIME: &str = "greptime.series.time"; +const GRID: &str = "greptime.series.grid"; +/// Metadata key of the versioned label decode description carried by key-encoded frames. +const LABEL_DECODE: &str = "greptime.series.label_decode"; + +/// Version of the label decode description this revision writes and reads. +pub const LABEL_DECODE_VERSION: u32 = 1; /// Whether a raw frame carries only the complete key or explicit metric labels as well. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -56,7 +98,166 @@ pub enum RawLabelMode { Explicit, } +/// Primary-key encoding of the labels a key-encoded frame carries. +/// +/// This mirrors the storage contract's `PrimaryKeyEncoding` without making this crate depend on +/// it; Dense and Sparse keys use different decode layouts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LabelDecodeEncoding { + /// Dense key: every primary-key field is encoded in order, with no ids on the wire. + Dense, + /// Sparse key: `(column id, value)` pairs, so the layout only frames the field stream. + Sparse, +} + +/// One ordered key column needed to decode key-encoded labels. +/// +/// Only tag columns reach this list: region metadata rejects a primary key that holds the time +/// index or a non-`Tag` column, and it requires the primary key to name every tag column. The +/// array's position is therefore the encoding position, with nothing to disambiguate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LabelDecodeColumn { + /// Physical column id of this key column. + pub column_id: u32, + /// Physical column name; a logical tag of the same id must repeat it. + pub name: String, + /// Physical data type of the encoded value, in [`ConcreteDataType`]'s own serde form. Dense + /// keys carry no types on the wire, so the codec is reconstructed from this exact ordered type + /// list. + pub physical_type: ConcreteDataType, +} + +/// One logical tag name bound to a primary-key column. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LabelDecodeTag { + /// Physical column id this logical tag reads. + pub column_id: u32, + /// Logical tag name exposed to queries. + pub name: String, +} + +/// Versioned, self-contained description of how to decode key-encoded labels. +/// +/// It carries exactly what decoding a primary-key label source reads and nothing else: no region +/// identity, no field columns, no time index. A consumer that receives the frame schema can +/// therefore reconstruct an equivalent decoder without a scan-side binding, while a description +/// that is missing, of an unknown version, or structurally inconsistent is a hard error instead of +/// a silent fallback. +/// +/// It is JSON-encoded in one schema metadata entry, always through +/// [`raw_series_frame_schema_with_label_decode`], so both the producing and the expecting side of a +/// `greptime.series.*` exact metadata comparison see byte-identical text. The text is +/// self-describing but not a general interchange format: readers reject unknown fields here only +/// at this structure's own levels, while [`LabelDecodeColumn::physical_type`] is decoded by +/// [`ConcreteDataType`]'s own representation and is therefore exactly as stable as that type's +/// serde. The frozen text this revision writes is pinned by +/// `tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json`; see the module +/// documentation for what a change to it means. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LabelDecodeSpec { + /// Description version; see [`LABEL_DECODE_VERSION`]. + pub version: u32, + /// Metric name attached to every decoded label set; may be empty. + pub metric_name: String, + /// Primary-key encoding that determines the decode layout. + pub encoding: LabelDecodeEncoding, + /// Every tag column of the primary key, in primary-key encoding order. This is exactly the + /// set of encoded key fields: region metadata forbids a primary key from naming the time + /// index or a field column, and requires it to name every tag column, so no time-index or + /// field entry can appear here. The order is significant and is compared as written: a dense + /// decode reads field *n* at position *n*, so the same columns in another order describe a + /// different key, not the same key in another order. + pub primary_key: Vec, + /// Logical tags, as bound by the producer, in [`LabelDecodeSpec::canonicalize`] order. + /// + /// The order carries no meaning: the decoder sorts the tags by name when it binds them + /// (`src/mito-codec/src/labels.rs:235`), so a decoded label set does not depend on it. It is + /// nevertheless fixed rather than left to the caller, because this description is compared as + /// exact text across nodes: two producers with the same tags in another order must write + /// byte-identical text, or one side rejects the other as a schema mismatch. + /// + /// The semantic checks on these tags (reserved and internal names, name equality with the bound + /// column, membership in `primary_key`) are the decoder's, matching what a typed binding gets. + pub logical_tags: Vec, +} + +impl LabelDecodeSpec { + /// Puts the order-insensitive part of the description in its canonical order. + /// + /// [`raw_series_frame_schema_with_label_decode`] applies this to a copy before serializing, and + /// so does the description a binding derives (in `common_query::series_frame`), so both sides of + /// a text comparison produce the same bytes and a reader can compare descriptions structurally. + /// `primary_key` is deliberately untouched: its order is the encoding order. + /// + /// The writer canonicalizes a **copy** on purpose. A caller's binding keeps its own + /// `logical_tags` order, and that order is meaningful elsewhere: `src/table/src/table/scan.rs` + /// compares two bindings' tag lists positionally (`lt.iter().zip(rt.iter())`), so reordering the + /// caller's list here would make two bindings for the same labels compare unequal. Only the + /// serialized text is canonical; nothing outside it is rewritten. + /// + /// Sorting by `(column_id, name)` is a total order for a validated description, which names + /// each tag column and each tag name at most once, so the canonical form is unique. + pub fn canonicalize(&mut self) { + self.logical_tags + .sort_unstable_by(|a, b| (a.column_id, &a.name).cmp(&(b.column_id, &b.name))); + } + + /// Checks the version and the structural invariants a decoder relies on. + /// + /// Semantic binding checks (reserved names, internal columns, name equality against the bound + /// column) stay in the decoder so both a typed binding and a decoded description fail with the + /// same errors. + pub fn validate(&self) -> Result<()> { + if self.version != LABEL_DECODE_VERSION { + return Err(err(format!( + "unsupported label decode description version {}", + self.version + ))); + } + let mut column_ids = HashSet::with_capacity(self.primary_key.len()); + for column in &self.primary_key { + if !column_ids.insert(column.column_id) { + return Err(err(format!( + "duplicate primary-key column id {} in label decode description", + column.column_id + ))); + } + } + let mut tag_ids = HashSet::with_capacity(self.logical_tags.len()); + let mut tag_names = HashSet::with_capacity(self.logical_tags.len()); + for tag in &self.logical_tags { + if !tag_ids.insert(tag.column_id) { + return Err(err(format!( + "duplicate logical tag column id {} in label decode description", + tag.column_id + ))); + } + if !tag_names.insert(tag.name.as_str()) { + return Err(err(format!( + "duplicate logical tag {} in label decode description", + tag.name + ))); + } + if !column_ids.contains(&tag.column_id) { + return Err(err(format!( + "logical tag {} is not a primary-key column of the label decode description", + tag.name + ))); + } + } + Ok(()) + } +} + /// Builds the typed schema for a raw frame, preserving logical field names and order. +/// +/// This is the column layout shared with the frame builder and decoder; it deliberately carries no +/// label decode description. A key-encoded stream must be declared through +/// [`raw_series_frame_schema_with_label_decode`], which refuses such a layout without one. pub fn raw_series_frame_schema(schema: &SeriesSchema, mode: RawLabelMode) -> Result { if schema.eval_grid.is_some() { return invalid_series("raw series schema cannot have an evaluation grid"); @@ -137,6 +338,129 @@ pub fn raw_series_frame_schema(schema: &SeriesSchema, mode: RawLabelMode) -> Res Ok(Arc::new(Schema::new_with_metadata(columns, metadata))) } +/// Builds the typed schema for a raw frame together with its label decode description. +/// +/// The description is written only for [`RawLabelMode::Key`], where labels stay encoded in +/// `read_key`; explicit-label frames carry complete labels and must not declare one. Both +/// directions are enforced here so a frame cannot claim a label layout it does not describe. +pub fn raw_series_frame_schema_with_label_decode( + schema: &SeriesSchema, + mode: RawLabelMode, + decode: Option<&LabelDecodeSpec>, +) -> Result { + match (mode, decode) { + (RawLabelMode::Key, None) => { + return Err(err( + "key-encoded raw frames must carry a label decode description", + )); + } + (RawLabelMode::Explicit, Some(_)) => { + return Err(err("explicit raw frames carry no label decode description")); + } + (RawLabelMode::Key, Some(_)) | (RawLabelMode::Explicit, None) => {} + } + let layout = raw_series_frame_schema(schema, mode)?; + let Some(decode) = decode else { + return Ok(layout); + }; + // Writing is faithful: the description records what the producer declares, and nothing is + // rewritten or dropped. The one exception is the order of `logical_tags`, which carries no + // meaning to a decoder and which this text nevertheless has to fix so two producers of the same + // labels write the same bytes; a copy is canonicalized and the caller's value is untouched. A + // description this revision cannot honour is rejected when it is read, which is the only + // direction a consumer can rely on. + let mut canonical = decode.clone(); + canonical.canonicalize(); + let encoded = serde_json::to_string(&canonical) + .map_err(|e| err(format!("cannot encode label decode description: {e}")))?; + let mut metadata = layout.metadata().clone(); + metadata.insert(LABEL_DECODE.to_string(), encoded); + Ok(Arc::new(Schema::new_with_metadata( + layout.fields().clone(), + metadata, + ))) +} + +/// Recovers the label decode description a raw frame schema declares. +/// +/// Returns `None` for an explicit-label frame, which must not declare one. A key-encoded frame must +/// declare one, so a missing description is an error rather than a reason to reuse a caller-side +/// binding. A present description is parsed strictly: an unknown version, a missing or unknown +/// field at this structure's own levels, a type mismatch, or a description whose logical tags do +/// not name listed key columns is an error, never a silent default. Only the description's own +/// levels reject unknown fields; [`LabelDecodeColumn::physical_type`] is a +/// [`ConcreteDataType`] and accepts whatever that type's own serde accepts. +pub fn raw_series_frame_label_decode(schema: &Schema) -> Result> { + let mode = match schema.metadata().get(LABELS).map(String::as_str) { + Some("key") => RawLabelMode::Key, + Some("explicit") => RawLabelMode::Explicit, + _ => return Err(err("raw frame schema declares no known label mode")), + }; + let decode = match schema.metadata().get(LABEL_DECODE) { + None => None, + Some(encoded) => { + let decode: LabelDecodeSpec = serde_json::from_str(encoded) + .map_err(|e| err(format!("malformed label decode description: {e}")))?; + decode.validate()?; + Some(decode) + } + }; + match (mode, &decode) { + (RawLabelMode::Key, None) => Err(err( + "key-encoded raw frame declares no label decode description", + )), + (RawLabelMode::Explicit, Some(_)) => Err(err( + "explicit raw frame declares a label decode description", + )), + (RawLabelMode::Key, Some(_)) | (RawLabelMode::Explicit, None) => Ok(decode), + } +} + +/// Reconstructs the series schema a raw frame declares. +/// +/// A raw frame names its payload fields by name only: every member of the `fields` struct carries +/// the same payload type ([`raw_series_frame_schema`]), and [`SeriesSchema`] holds nothing else per +/// field. The reconstruction is therefore exact rather than a projection — the declared names in +/// declared order, and no evaluation grid, which a raw frame cannot carry. +/// +/// The reserved layout, content, and time metadata are checked here together with the absence of an +/// evaluation grid, so a frame this revision cannot read is rejected instead of being silently +/// interpreted as the current layout. +pub fn raw_series_frame_series_schema(schema: &Schema) -> Result { + for (key, expected) in [(LAYOUT, "v1"), (CONTENT, "raw"), (TIME, "timestamps")] { + if schema.metadata().get(key).map(String::as_str) != Some(expected) { + return Err(err(format!("raw frame declares no known {key} value"))); + } + } + if schema + .metadata() + .keys() + .any(|key| key == GRID || key.starts_with("greptime.series.grid.")) + { + return Err(err("raw series schema cannot have an evaluation grid")); + } + let fields = schema + .fields() + .iter() + .find(|field| field.name() == "fields") + .ok_or_else(|| err("raw frame declares no fields column"))?; + let DataType::Struct(members) = fields.data_type() else { + return Err(err("raw frame fields column is not a struct")); + }; + if members.is_empty() { + return Err(err("raw series schema must have fields")); + } + Ok(SeriesSchema { + fields: members + .iter() + .map(|member| FieldSpec { + name: Arc::from(member.name().as_str()), + }) + .collect(), + eval_grid: None, + }) +} + /// Bounded multi-row builder for finite raw frame batches. /// /// Rows accumulate until the row limit or the estimated byte limit is reached; the next @@ -871,7 +1195,7 @@ fn decode_raw_series_frame( if actual .metadata() .keys() - .any(|key| key == "greptime.series.grid" || key.starts_with("greptime.series.grid.")) + .any(|key| key == GRID || key.starts_with("greptime.series.grid.")) || actual.fields() != expected.fields() { return invalid_series("raw frame schema mismatch"); diff --git a/src/datatypes/src/series/frame/tests.rs b/src/datatypes/src/series/frame/tests.rs index 976a433ee27..619db9d3e89 100644 --- a/src/datatypes/src/series/frame/tests.rs +++ b/src/datatypes/src/series/frame/tests.rs @@ -3058,3 +3058,530 @@ fn decoder_reuses_cached_schema_and_rejects_schema_evolution() { assert!(explicit_decoder.decode(&first).is_err()); assert!(key_decoder.decode(&nameless).is_err()); } + +/// A dense description carrying the ordered key columns and the logical tags the decoder reads. +fn dense_description() -> LabelDecodeSpec { + LabelDecodeSpec { + version: LABEL_DECODE_VERSION, + metric_name: "cpu_usage".to_string(), + encoding: LabelDecodeEncoding::Dense, + primary_key: vec![ + LabelDecodeColumn { + column_id: 1, + name: "host".to_string(), + physical_type: crate::data_type::ConcreteDataType::string_datatype(), + }, + LabelDecodeColumn { + column_id: 2, + name: "region".to_string(), + physical_type: crate::data_type::ConcreteDataType::string_datatype(), + }, + ], + logical_tags: vec![LabelDecodeTag { + column_id: 1, + name: "host".to_string(), + }], + } +} + +#[test] +fn label_decode_description_roundtrips_through_schema_metadata() { + let description = dense_description(); + let key = raw_series_frame_schema_with_label_decode( + &test_schema(&["value"]), + RawLabelMode::Key, + Some(&description), + ) + .unwrap(); + assert_eq!( + raw_series_frame_label_decode(&key).unwrap(), + Some(description.clone()) + ); + // The description is metadata-only: the column contract is untouched. + assert_eq!( + key.fields(), + raw_series_frame_schema(&test_schema(&["value"]), RawLabelMode::Key) + .unwrap() + .fields() + ); + assert_eq!(key.metadata().get(LABELS).map(String::as_str), Some("key")); +} + +#[test] +fn explicit_frames_declare_no_label_decode_description() { + let schema = test_schema(&["value"]); + let explicit = + raw_series_frame_schema_with_label_decode(&schema, RawLabelMode::Explicit, None).unwrap(); + assert!(explicit.metadata().get(LABEL_DECODE).is_none()); + assert_eq!(raw_series_frame_label_decode(&explicit).unwrap(), None); + // A key frame without a description, or an explicit frame with one, is rejected instead of + // being written as a frame the consumer cannot decode. + assert!( + raw_series_frame_schema_with_label_decode(&schema, RawLabelMode::Key, None).is_err(), + "key-encoded frames must describe their labels" + ); + assert!( + raw_series_frame_schema_with_label_decode( + &schema, + RawLabelMode::Explicit, + Some(&dense_description()) + ) + .is_err(), + "explicit frames carry complete labels" + ); +} + +/// The raw frame's own columns with exactly the supplied metadata map. +fn schema_with_metadata(metadata: Vec<(String, String)>) -> Schema { + let layout = raw_series_frame_schema(&test_schema(&["value"]), RawLabelMode::Key).unwrap(); + Schema::new_with_metadata(layout.fields().clone(), metadata.into_iter().collect()) +} + +/// The four metadata entries every raw frame declares, with the given label mode. +fn base_metadata(labels: &str) -> Vec<(String, String)> { + [ + ("greptime.series.layout", "v1"), + ("greptime.series.content", "raw"), + ("greptime.series.labels", labels), + ("greptime.series.time", "timestamps"), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +#[test] +fn label_decode_description_rejects_inconsistent_bindings() { + let schema = test_schema(&["value"]); + let mut cases = Vec::new(); + + let mut duplicate_column = dense_description(); + duplicate_column.primary_key[1].column_id = 1; + cases.push(("duplicate primary-key column", duplicate_column)); + + let mut unbound_tag = dense_description(); + unbound_tag.logical_tags[0].column_id = 3; + cases.push(("logical tag outside the primary key", unbound_tag)); + + let mut duplicate_tag = dense_description(); + let tag = duplicate_tag.logical_tags[0].clone(); + duplicate_tag.logical_tags.push(tag); + cases.push(("duplicate logical tag", duplicate_tag)); + + for (label, description) in cases { + // Writing is faithful: the description records what the producer declares. Reading is + // where a description this revision cannot honour is rejected, so a producer cannot turn a + // bad binding into a frame a consumer would decode under silently. + let frame = raw_series_frame_schema_with_label_decode( + &schema, + RawLabelMode::Key, + Some(&description), + ) + .unwrap_or_else(|error| panic!("{label}: {error}")); + // The writer records what the producer declares, so the rejection has to come from the + // reader; one assertion covers it, since the frame it reads is the text just written. + assert!( + raw_series_frame_label_decode(&frame).is_err(), + "{label}: an inconsistent description is rejected when read" + ); + } +} + +/// A frame declaring no label mode has no label layout to describe. The rest of the parse and +/// validation rejections (unknown version, missing/unknown/wrongly typed fields, malformed JSON, +/// and mode and description disagreeing in either direction) are pinned case for case by +/// [`raw_series_label_decode_metadata_fixture`], so they are not re-asserted here. +#[test] +fn label_decode_rejects_a_frame_without_a_label_mode() { + let without_mode = base_metadata("key") + .into_iter() + .filter(|(key, _)| key != LABELS) + .collect(); + assert!( + raw_series_frame_label_decode(&schema_with_metadata(without_mode)).is_err(), + "a frame without a label mode has no label layout to describe" + ); +} + +#[test] +fn raw_series_label_decode_metadata_fixture() { + use std::collections::HashMap; + + #[derive(serde::Deserialize)] + struct Case { + name: String, + metadata: HashMap, + /// The encoding a decoding consumer expects, or `null` when the fixture must be rejected. + encoding: Option, + } + + let cases: Vec = serde_json::from_str(include_str!( + "../../../../../tests/compatibility/wire/raw_series_label_decode/v1_metadata.json" + )) + .unwrap(); + for case in cases { + let layout = raw_series_frame_schema(&test_schema(&["value"]), RawLabelMode::Key).unwrap(); + let metadata = case + .metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let frame_schema = Schema::new_with_metadata(layout.fields().clone(), metadata); + match case.encoding.as_deref() { + None => assert!( + raw_series_frame_label_decode(&frame_schema).is_err(), + "{}: must be rejected", + case.name + ), + Some(expected) => { + let decode = raw_series_frame_label_decode(&frame_schema) + .unwrap_or_else(|error| panic!("{}: {error}", case.name)) + .unwrap_or_else(|| panic!("{}: no description", case.name)); + let encoding = match decode.encoding { + LabelDecodeEncoding::Dense => "dense", + LabelDecodeEncoding::Sparse => "sparse", + }; + assert_eq!(encoding, expected, "{}", case.name); + assert_eq!(decode.version, LABEL_DECODE_VERSION, "{}", case.name); + assert_eq!(decode.metric_name, "cpu_usage", "{}", case.name); + assert_eq!( + decode.primary_key, + vec![ + LabelDecodeColumn { + column_id: 1, + name: "host".to_string(), + physical_type: crate::data_type::ConcreteDataType::string_datatype(), + }, + LabelDecodeColumn { + column_id: 2, + name: "shard".to_string(), + physical_type: crate::data_type::ConcreteDataType::uint32_datatype(), + }, + ], + "{}: the ordered key columns are decoded in place", + case.name + ); + assert_eq!( + decode.logical_tags, + vec![ + LabelDecodeTag { + column_id: 1, + name: "host".to_string(), + }, + LabelDecodeTag { + column_id: 2, + name: "shard".to_string(), + }, + ], + "{}", + case.name + ); + if expected == "dense" { + // The fixture text is the encoder's own output, byte for byte: re-encoding the + // description it decodes to must reproduce it exactly. + let roundtrip = raw_series_frame_schema_with_label_decode( + &test_schema(&["value"]), + RawLabelMode::Key, + Some(&decode), + ) + .unwrap(); + assert_eq!( + roundtrip.metadata().get(LABEL_DECODE), + case.metadata.get(LABEL_DECODE), + "{}: re-encoding reproduces the fixture text", + case.name + ); + } + } + } + } +} + +/// A tag column typed as a timestamp is described like any other key column: the description +/// carries its physical type, and the text is exactly what the encoder wrote. +#[test] +fn label_decode_description_carries_a_timestamp_typed_tag_column() { + let mut description = dense_description(); + description.primary_key[1].name = "observed".to_string(); + description.primary_key[1].physical_type = + crate::data_type::ConcreteDataType::timestamp_millisecond_datatype(); + description.logical_tags.push(LabelDecodeTag { + column_id: 2, + name: "observed".to_string(), + }); + let frame = raw_series_frame_schema_with_label_decode( + &test_schema(&["value"]), + RawLabelMode::Key, + Some(&description), + ) + .unwrap(); + let text = frame.metadata().get(LABEL_DECODE).unwrap(); + // The expected text is the encoder's real output for a timestamp-typed tag column. + assert!( + text.contains("\"physical_type\":{\"Timestamp\":{\"Millisecond\":null}}"), + "the encoder wrote a timestamp type: {text}" + ); + let recovered = raw_series_frame_label_decode(&frame).unwrap().unwrap(); + assert_eq!(recovered, description); + assert_eq!( + recovered.primary_key[1].physical_type, + crate::data_type::ConcreteDataType::timestamp_millisecond_datatype() + ); +} + +/// Two producers that list the same logical tags in a different order write the same bytes. +/// +/// The tag order carries no meaning to a decoder, so the writer sorts its own copy before +/// serializing and leaves the caller's value alone. The encoding order of `primary_key` is a +/// different thing entirely: it is the order of the key columns, so it is written as given. +#[test] +fn label_decode_text_is_independent_of_the_callers_tag_order() { + let mut ascending = dense_description(); + ascending.logical_tags = vec![ + LabelDecodeTag { + column_id: 1, + name: "host".to_string(), + }, + LabelDecodeTag { + column_id: 2, + name: "region".to_string(), + }, + ]; + let mut descending = ascending.clone(); + descending.logical_tags.reverse(); + // The encoding order of the key columns is not the tag order: reverse it in the second + // description so a writer that sorted `primary_key` too would be caught below. + descending.primary_key.reverse(); + + let write = |description: &LabelDecodeSpec| { + let frame = raw_series_frame_schema_with_label_decode( + &test_schema(&["value"]), + RawLabelMode::Key, + Some(description), + ) + .unwrap(); + frame.metadata().get(LABEL_DECODE).unwrap().clone() + }; + + let same_tags = { + let mut reordered = ascending.clone(); + reordered.logical_tags.reverse(); + write(&reordered) + }; + assert_eq!( + write(&ascending), + same_tags, + "two tag orders wrote different bytes" + ); + assert_eq!( + ascending.logical_tags, + vec![ + LabelDecodeTag { + column_id: 1, + name: "host".to_string(), + }, + LabelDecodeTag { + column_id: 2, + name: "region".to_string(), + }, + ], + "the writer reordered the caller's description" + ); + // The key columns keep the order they were given, so the two descriptions are different + // frames rather than the same bytes: that is the part canonicalization must not touch. + assert_ne!( + write(&ascending), + write(&descending), + "the writer reordered the encoding columns" + ); + let recovered = raw_series_frame_label_decode( + &raw_series_frame_schema_with_label_decode( + &test_schema(&["value"]), + RawLabelMode::Key, + Some(&descending), + ) + .unwrap(), + ) + .unwrap() + .unwrap(); + assert_eq!( + recovered.primary_key, descending.primary_key, + "the recovered key order is not the encoding order" + ); +} +/// One entry of `v1_physical_types.json`: a [`ConcreteDataType`] variant and the exact text this +/// revision's serde writes for a representative value of it. +#[derive(serde::Deserialize)] +struct FrozenPhysicalType { + /// The [`ConcreteDataType`] variant, as [`concrete_datatype_variants`] spells it. + variant: String, + /// The `serde_json` text this revision writes for that variant's representative value. + text: String, +} + +/// Spells every [`ConcreteDataType`] variant exactly once, together with one representative value of +/// it. +/// +/// Everything that has to notice a new variant is derived from this one list: +/// +/// - [`variant_name`] matches on it with **no wildcard arm**, so adding a variant to +/// [`ConcreteDataType`] is a compile error until the variant is added here. +/// - [`datatype_representatives`] yields one value per listed variant, so the same edit is what +/// supplies the value the tests read the serde text from. +/// - [`VARIANT_NAMES`] is generated from it too, which is what +/// [`the_frozen_text_covers_every_datatype_variant`] requires a fixture entry for. +macro_rules! concrete_datatype_variants { + ($($variant:ident => $value:expr),+ $(,)?) => { + /// The [`ConcreteDataType`] variant a value belongs to, spelled as the fixture names it. + /// + /// Exhaustive on purpose: a new variant of [`ConcreteDataType`] makes this match fail to + /// compile, which is what makes a missing fixture entry impossible to land. + fn variant_name(data_type: &ConcreteDataType) -> &'static str { + match data_type { + $(ConcreteDataType::$variant(_) => stringify!($variant),)+ + } + } + + /// One representative value per variant, in the order listed above. + fn datatype_representatives() -> Vec { + vec![$($value),+] + } + + /// Every [`ConcreteDataType`] variant name, in the order listed above. + const VARIANT_NAMES: &[&str] = &[$(stringify!($variant)),+]; + }; +} + +concrete_datatype_variants!( + Null => ConcreteDataType::null_datatype(), + Boolean => ConcreteDataType::boolean_datatype(), + Int8 => ConcreteDataType::int8_datatype(), + Int16 => ConcreteDataType::int16_datatype(), + Int32 => ConcreteDataType::int32_datatype(), + Int64 => ConcreteDataType::int64_datatype(), + UInt8 => ConcreteDataType::uint8_datatype(), + UInt16 => ConcreteDataType::uint16_datatype(), + UInt32 => ConcreteDataType::uint32_datatype(), + UInt64 => ConcreteDataType::uint64_datatype(), + Float32 => ConcreteDataType::float32_datatype(), + Float64 => ConcreteDataType::float64_datatype(), + Decimal128 => ConcreteDataType::decimal128_datatype(10, 2), + Binary => ConcreteDataType::binary_datatype(), + String => ConcreteDataType::string_datatype(), + Date => ConcreteDataType::date_datatype(), + Timestamp => ConcreteDataType::timestamp_millisecond_datatype(), + Time => ConcreteDataType::time_microsecond_datatype(), + Duration => ConcreteDataType::duration_nanosecond_datatype(), + Interval => ConcreteDataType::interval_month_day_nano_datatype(), + List => ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::string_datatype())), + Dictionary => ConcreteDataType::dictionary_datatype( + ConcreteDataType::int32_datatype(), + ConcreteDataType::string_datatype(), + ), + Struct => ConcreteDataType::struct_datatype(crate::types::StructType::new(Arc::new(vec![ + crate::types::StructField::new("host", ConcreteDataType::string_datatype(), false), + crate::types::StructField::new("port", ConcreteDataType::uint16_datatype(), true), + ]))), + Json => ConcreteDataType::json_datatype(), + Vector => ConcreteDataType::vector_datatype(3), +); + +/// The frozen text of every [`ConcreteDataType`] variant, as a label decode description embeds it. +const FROZEN_PHYSICAL_TYPES: &str = include_str!( + "../../../../../tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json" +); + +fn frozen_physical_types() -> Vec { + serde_json::from_str(FROZEN_PHYSICAL_TYPES).expect("the frozen physical-type fixture parses") +} + +/// The representative value of one variant, named the way the fixture names it. +fn representative(variant: &str) -> ConcreteDataType { + VARIANT_NAMES + .iter() + .zip(datatype_representatives()) + .find(|(name, _)| **name == variant) + .unwrap_or_else(|| panic!("{variant}: no representative value is listed for this variant")) + .1 +} + +/// The text a description embeds for a key column's type is not this module's to spell: it is +/// [`ConcreteDataType`]'s own serde, so it changes whenever that type's serde does. +/// +/// `v1_physical_types.json` freezes that text for every variant. This test fails when a variant no +/// longer parses back from its frozen text, when it no longer re-serializes to it, or when the +/// parsed value is no longer the type that variant names — which is what a silent serde change +/// looks like. Since the frame text is compared byte for byte across nodes, such a change is a +/// [`LABEL_DECODE_VERSION`] bump, and this test is the alarm that raises it. +#[test] +fn every_datatype_round_trips_through_its_frozen_text() { + use crate::data_type::DataType as _; + + for entry in frozen_physical_types() { + let parsed: ConcreteDataType = serde_json::from_str(&entry.text).unwrap_or_else(|error| { + panic!("{}: {} cannot be read: {error}", entry.variant, entry.text) + }); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + entry.text, + "{}: re-serializes to the frozen text", + entry.variant + ); + assert_eq!( + parsed.name(), + representative(&entry.variant).name(), + "{}: the frozen text still names the same type", + entry.variant + ); + assert_eq!( + parsed, + representative(&entry.variant), + "{}: the frozen text still describes the same value", + entry.variant + ); + } +} + +/// The fixture above only freezes a variant if it has an entry, so every variant must have one. +/// +/// This test is what makes that hold: [`variant_name`] matches every [`ConcreteDataType`] variant +/// with no wildcard arm, so a new variant does not compile until it is listed in +/// [`concrete_datatype_variants`]; that listing also extends [`VARIANT_NAMES`] and +/// [`datatype_representatives`], and this test then requires a fixture entry for it and rejects an +/// entry naming a variant that does not exist. +#[test] +fn the_frozen_text_covers_every_datatype_variant() { + let frozen = frozen_physical_types(); + + for data_type in datatype_representatives() { + let variant = variant_name(&data_type); + let entry = frozen + .iter() + .find(|entry| entry.variant == variant) + .unwrap_or_else(|| { + panic!("{variant}: v1_physical_types.json has no entry for this datatype variant") + }); + // The entry's text is this variant's text: read it back and name the variant it describes. + let parsed: ConcreteDataType = serde_json::from_str(&entry.text) + .unwrap_or_else(|error| panic!("{variant}: {} is unreadable: {error}", entry.text)); + assert_eq!( + variant_name(&parsed), + variant, + "{variant}: the entry's text describes this variant" + ); + } + + // Nothing in the fixture names a variant that does not exist, and no variant is listed twice. + for entry in &frozen { + assert!( + VARIANT_NAMES.contains(&entry.variant.as_str()), + "{}: the fixture names a variant ConcreteDataType does not have", + entry.variant + ); + } + assert_eq!( + frozen.len(), + VARIANT_NAMES.len(), + "one fixture entry per datatype variant, no duplicates" + ); +} diff --git a/src/mito-codec/src/error.rs b/src/mito-codec/src/error.rs index b266ea9b04f..e88af039d88 100644 --- a/src/mito-codec/src/error.rs +++ b/src/mito-codec/src/error.rs @@ -81,6 +81,15 @@ pub enum Error { location: Location, }, + #[snafu(display("Invalid label decode description"))] + InvalidLabelDecodeDescription { + // Box the source to reduce the size of the error. + #[snafu(source(from(datatypes::error::Error, Box::new)))] + source: Box, + #[snafu(implicit)] + location: Location, + }, + #[snafu(display("Encode null value"))] IndexEncodeNull { #[snafu(implicit)] @@ -108,6 +117,7 @@ impl ErrorExt for Error { StatusCode::InvalidArguments } NotSupportedField { .. } | UnsupportedOperation { .. } => StatusCode::Unsupported, + InvalidLabelDecodeDescription { source, .. } => source.status_code(), InvalidSparsePrimaryKey { .. } | InvalidDensePrimaryKey { .. } => { StatusCode::InvalidArguments } diff --git a/src/mito-codec/src/labels.rs b/src/mito-codec/src/labels.rs index fe4e3bfbde0..92e28574ffa 100644 --- a/src/mito-codec/src/labels.rs +++ b/src/mito-codec/src/labels.rs @@ -17,11 +17,12 @@ use std::sync::Arc; use api::v1::SemanticType; use common_telemetry::debug; +use datatypes::data_type::ConcreteDataType; +use datatypes::series::frame::{LabelDecodeEncoding, LabelDecodeSpec}; use datatypes::series::{Label, MetricName}; use datatypes::value::Value; -use snafu::OptionExt; +use snafu::{OptionExt, ResultExt}; use store_api::codec::PrimaryKeyEncoding; -use store_api::metadata::RegionMetadataRef; use store_api::metric_engine_consts::is_metric_engine_internal_column; use store_api::region_engine::RawLabelSource; use store_api::storage::{ @@ -30,8 +31,10 @@ use store_api::storage::{ }; use crate::error::{ - InvalidDensePrimaryKeySnafu, InvalidSparsePrimaryKeySnafu, Result, UnsupportedOperationSnafu, + InvalidDensePrimaryKeySnafu, InvalidLabelDecodeDescriptionSnafu, InvalidSparsePrimaryKeySnafu, + Result, UnsupportedOperationSnafu, }; +use crate::row_converter::SortField; use crate::row_converter::dense::DensePrimaryKeyCodec; use crate::row_converter::sparse::{SparseOffsetsCache, SparsePrimaryKeyView}; @@ -68,10 +71,41 @@ struct BoundLabel { position: usize, } -/// Decodes labels on demand using the metadata snapshot bound to a raw-label source. -pub struct PrimaryKeyLabelDecoder { +/// One ordered key column of the minimal decode layout. +struct KeyColumn { + id: ColumnId, + name: String, + data_type: ConcreteDataType, +} + +/// Minimal primary-key layout labels can be decoded from. +/// +/// It holds only what decoding reads: the metric name, the primary-key encoding, and the ordered +/// primary-key columns with their physical types. A scan-bound [`RawLabelSource::PrimaryKey`] and a +/// frame schema's [`LabelDecodeSpec`] both reduce to this view, so both bind the same decoder. +/// Every key column is a `Tag` column, which is checked when a decoder is bound, so the layout +/// carries no per-column role. +struct KeyLayout { metric_name: Arc, - physical_metadata: RegionMetadataRef, + encoding: PrimaryKeyEncoding, + columns: Vec, +} + +impl KeyLayout { + /// Finds a listed primary-key column. + fn column(&self, id: ColumnId) -> Option<&KeyColumn> { + self.columns.iter().find(|column| column.id == id) + } + + /// Returns the position of a listed primary-key column. + fn position(&self, id: ColumnId) -> Option { + self.columns.iter().position(|column| column.id == id) + } +} + +/// Decodes labels on demand using the minimal primary-key layout bound to a raw-label source. +pub struct PrimaryKeyLabelDecoder { + key: KeyLayout, complete: bool, include_name: bool, labels: Vec, @@ -85,6 +119,9 @@ impl PrimaryKeyLabelDecoder { /// Explicit sources already contain complete labels and are rejected. Dense decoding /// requires the full key layout described by the bound snapshot; compatibility with /// older key layouts must be normalized upstream. + /// + /// Every primary-key column must be a `Tag` column; [`RegionMetadata::validate`] enforces it, so + /// this only rejects a snapshot built without validation. pub fn new(source: &RawLabelSource, projection: LabelProjection) -> Result { let RawLabelSource::PrimaryKey { metric_name, @@ -98,36 +135,113 @@ impl PrimaryKeyLabelDecoder { .fail(); }; let metadata = physical_metadata.as_ref(); - let mut labels = Vec::with_capacity(logical_tags.len()); + if let Some(column) = metadata + .primary_key_columns() + .find(|column| column.semantic_type != SemanticType::Tag) + { + return InvalidSparsePrimaryKeySnafu { + reason: format!( + "primary-key column {} is not a tag column", + column.column_schema.name + ), + } + .fail(); + } + let key = KeyLayout { + metric_name: metric_name.clone(), + encoding: metadata.primary_key_encoding, + columns: metadata + .primary_key_columns() + .map(|column| KeyColumn { + id: column.column_id, + name: column.column_schema.name.clone(), + data_type: column.column_schema.data_type.clone(), + }) + .collect(), + }; + Self::bind( + key, + logical_tags + .iter() + .map(|tag| (tag.column_id, tag.name.as_ref())), + projection, + ) + } + + /// Binds a decoder to a frame schema's label decode description and projection. + /// + /// This is the decoding path for a consumer that received only the raw frame schema: the + /// description carries the metric name, the primary-key encoding, the ordered key columns with + /// their physical types, and the logical tags. The description is validated first, so a version + /// or structure this revision cannot honour is an error instead of a best-effort decode. + /// + /// The description lists exactly the encoded key fields, and region metadata guarantees each + /// of them is a tag column, so no per-column role is carried or re-checked; an array position + /// is an encoding position. The logical tags are still checked here, one by one, exactly as + /// for a typed binding, which is the only place the decoder inspects them (a listed column no + /// tag selects is not examined). + pub fn from_decode_spec(spec: &LabelDecodeSpec, projection: LabelProjection) -> Result { + spec.validate() + .context(InvalidLabelDecodeDescriptionSnafu)?; + let key = KeyLayout { + metric_name: Arc::from(spec.metric_name.as_str()), + encoding: match spec.encoding { + LabelDecodeEncoding::Dense => PrimaryKeyEncoding::Dense, + LabelDecodeEncoding::Sparse => PrimaryKeyEncoding::Sparse, + }, + columns: spec + .primary_key + .iter() + .map(|column| KeyColumn { + id: column.column_id, + name: column.name.clone(), + data_type: column.physical_type.clone(), + }) + .collect(), + }; + Self::bind( + key, + spec.logical_tags + .iter() + .map(|tag| (tag.column_id, tag.name.as_str())), + projection, + ) + } + + /// Binds the ordered logical tags of `key` to one projection and one decode layout. + fn bind<'a, I>(key: KeyLayout, tags: I, projection: LabelProjection) -> Result + where + I: IntoIterator, + { + let mut labels = Vec::new(); let mut names = HashSet::new(); let mut ids = HashSet::new(); - for tag in logical_tags.iter() { - let column = metadata.column_by_id(tag.column_id); - if !names.insert(tag.name.clone()) - || !ids.insert(tag.column_id) - || tag.name.as_ref() == "__name__" - || is_internal_column(&tag.name) - || is_metric_engine_internal_column(&tag.name) - || ReservedColumnId::is_reserved(tag.column_id) - || !column.is_some_and(|column| { - column.semantic_type == SemanticType::Tag - && column.column_schema.name.as_str() == tag.name.as_ref() - && metadata.primary_key.contains(&tag.column_id) - }) + let mut logical_tag_count = 0; + for (column_id, name) in tags { + logical_tag_count += 1; + if !names.insert(name) + || !ids.insert(column_id) + || name == "__name__" + || is_internal_column(name) + || is_metric_engine_internal_column(name) + || ReservedColumnId::is_reserved(column_id) + || !key + .column(column_id) + .is_some_and(|column| column.name == name) { return InvalidSparsePrimaryKeySnafu { - reason: format!("invalid logical tag binding {}", tag.name), + reason: format!("invalid logical tag binding {name}"), } .fail(); } - let position = metadata.primary_key_index(tag.column_id).context( - InvalidSparsePrimaryKeySnafu { - reason: format!("logical tag {} is not a primary-key column", tag.name), - }, - )?; + let position = key + .position(column_id) + .context(InvalidSparsePrimaryKeySnafu { + reason: format!("logical tag {name} is not a primary-key column"), + })?; labels.push(BoundLabel { - id: tag.column_id, - name: tag.name.clone(), + id: column_id, + name: Arc::from(name), position, }); } @@ -143,14 +257,19 @@ impl PrimaryKeyLabelDecoder { let requested: HashSet<_> = requested.iter().map(AsRef::as_ref).collect(); labels.retain(|label| requested.contains(label.name.as_ref())); } - let layout = match metadata.primary_key_encoding { + let layout = match key.encoding { PrimaryKeyEncoding::Sparse => Layout::Sparse(SparseOffsetsCache::new()), PrimaryKeyEncoding::Dense => Layout::Dense { - codec: DensePrimaryKeyCodec::new(metadata), + codec: DensePrimaryKeyCodec::with_fields( + key.columns + .iter() + .map(|column| (column.id, SortField::new(column.data_type.clone()))) + .collect(), + ), offsets: Vec::new(), }, }; - let encoding = match metadata.primary_key_encoding { + let encoding = match key.encoding { PrimaryKeyEncoding::Sparse => "sparse", PrimaryKeyEncoding::Dense => "dense", }; @@ -158,13 +277,12 @@ impl PrimaryKeyLabelDecoder { debug!( encoding, projection_mode, - logical_tag_count = logical_tags.len(), + logical_tag_count, selected_tag_count = labels.len(), "Constructed primary key label decoder" ); Ok(Self { - metric_name: metric_name.clone(), - physical_metadata: physical_metadata.clone(), + key, complete, include_name, labels, @@ -182,8 +300,8 @@ impl PrimaryKeyLabelDecoder { let mut values = Vec::with_capacity(self.labels.len() + usize::from(self.include_name)); let Self { layout, + key, labels, - physical_metadata, complete, value_scratch, .. @@ -192,12 +310,7 @@ impl PrimaryKeyLabelDecoder { Layout::Sparse(cache) => { let mut view = SparsePrimaryKeyView::new(read_key, cache)?; view.visit_label_ids(|id| { - if *complete - && !physical_metadata.column_by_id(id).is_some_and(|column| { - column.semantic_type == SemanticType::Tag - && physical_metadata.primary_key.contains(&id) - }) - { + if *complete && key.column(id).is_none() { return InvalidSparsePrimaryKeySnafu { reason: format!("unrecognized sparse tag id {id}"), } @@ -240,7 +353,7 @@ impl PrimaryKeyLabelDecoder { } if *complete { Ok(DecodedLabels::Complete( - MetricName::try_new(Some(self.metric_name.clone()), values).map_err(|error| { + MetricName::try_new(Some(key.metric_name.clone()), values).map_err(|error| { InvalidSparsePrimaryKeySnafu { reason: error.to_string(), } @@ -251,7 +364,7 @@ impl PrimaryKeyLabelDecoder { if self.include_name { values.push(Label { name: Arc::from("__name__"), - value: self.metric_name.clone(), + value: self.key.metric_name.clone(), }); values.sort_unstable_by(|a, b| a.name.cmp(&b.name)); } diff --git a/src/mito-codec/src/labels/tests.rs b/src/mito-codec/src/labels/tests.rs index 99710e3d9fa..88651bd1c35 100644 --- a/src/mito-codec/src/labels/tests.rs +++ b/src/mito-codec/src/labels/tests.rs @@ -209,7 +209,7 @@ fn dense_and_sparse_decode_complete_or_projected_labels() { } #[test] -fn projection_is_bound_to_snapshot_and_distinguishes_missing_and_empty() { +fn projection_snapshots_its_layout_and_distinguishes_missing_and_empty() { let original = metadata( &[ (21, "old", ConcreteDataType::string_datatype()), @@ -250,7 +250,10 @@ fn projection_is_bound_to_snapshot_and_distinguishes_missing_and_empty() { let mut all = PrimaryKeyLabelDecoder::new(&old_source, LabelProjection::All).unwrap(); drop(old_source); drop(original); - assert!(weak_metadata.upgrade().is_some()); + assert!( + weak_metadata.upgrade().is_none(), + "the decoder keeps its own key layout (ids, positions, types), not the region metadata" + ); assert_eq!( decoder.decode(&key).unwrap(), DecodedLabels::Projected(vec![Label { @@ -601,3 +604,109 @@ fn dense_typed_values_keep_exact_integer_string_representation() { "9007199254740993" ); } + +/// One field is enough: the frame schema only carries ordered payload fields and metadata. +fn frame_series_schema() -> Arc { + Arc::new(datatypes::series::samples::SeriesSchema { + fields: vec![datatypes::series::samples::FieldSpec { + name: Arc::from("value"), + }] + .into(), + eval_grid: None, + }) +} + +/// A key-encoded frame's schema describes its own labels: recovering the description from the +/// schema and decoding with it matches decoding with the original bound source, for both primary +/// key encodings and both projections. +#[test] +fn frame_label_decode_description_decodes_like_the_bound_source() { + for encoding in [PrimaryKeyEncoding::Dense, PrimaryKeyEncoding::Sparse] { + let bound = source( + string_metadata(encoding), + "requests_total", + &[(8, "zeta"), (41, "alpha")], + ); + let description = common_query::series_frame::label_decode_spec(&bound) + .expect("a primary-key binding describes its key layout"); + let frame_schema = datatypes::series::frame::raw_series_frame_schema_with_label_decode( + &frame_series_schema(), + datatypes::series::frame::RawLabelMode::Key, + Some(&description), + ) + .unwrap(); + let recovered = datatypes::series::frame::raw_series_frame_label_decode(&frame_schema) + .unwrap() + .expect("the frame declares its decode description"); + assert_eq!(recovered, description); + + let key = encoded(encoding, &values()); + for projection in [ + LabelProjection::All, + LabelProjection::Only(Arc::from([Arc::::from("alpha")])), + LabelProjection::Only(Arc::from([])), + ] { + let mut direct = PrimaryKeyLabelDecoder::new(&bound, projection.clone()).unwrap(); + let mut from_frame = + PrimaryKeyLabelDecoder::from_decode_spec(&recovered, projection).unwrap(); + assert_eq!( + from_frame.decode(&key).unwrap(), + direct.decode(&key).unwrap() + ); + } + } +} + +/// A description this revision cannot honour is rejected when a decoder is bound, not decoded +/// best-effort. +#[test] +fn frame_label_decode_description_rejects_unsupported_version() { + let bound = source( + string_metadata(PrimaryKeyEncoding::Dense), + "requests_total", + &[(8, "zeta")], + ); + let description = common_query::series_frame::label_decode_spec(&bound).unwrap(); + let mut future = description.clone(); + future.version += 1; + assert!(PrimaryKeyLabelDecoder::from_decode_spec(&future, LabelProjection::All).is_err()); + + // The description must list the column a logical tag reads. + let mut unbound = description; + unbound.primary_key.retain(|column| column.column_id != 8); + assert!(PrimaryKeyLabelDecoder::from_decode_spec(&unbound, LabelProjection::All).is_err()); +} + +/// A primary-key column that is not a `Tag` column is rejected when a decoder is bound, because the +/// layout records no per-column role and decodes every listed key column as a tag. +/// +/// `RegionMetadata::validate` rejects such metadata, so this snapshot is built without validation to +/// exercise the check the decoder performs itself. +#[test] +fn binding_rejects_a_primary_key_column_that_is_not_a_tag() { + let mut builder = RegionMetadataBuilder::new(RegionId::new(7, 1)); + builder.push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ), + semantic_type: SemanticType::Timestamp, + column_id: 0, + }); + builder.push_column_metadata(ColumnMetadata { + column_schema: ColumnSchema::new("not_a_tag", ConcreteDataType::string_datatype(), true), + semantic_type: SemanticType::Field, + column_id: 8, + }); + builder.primary_key(vec![8]); + builder.primary_key_encoding(PrimaryKeyEncoding::Sparse); + let invalid = Arc::new(builder.build_without_validation().unwrap()); + + let bound = source(invalid, "m", &[(8, "not_a_tag")]); + let error = PrimaryKeyLabelDecoder::new(&bound, LabelProjection::All) + .err() + .expect("a non-tag primary-key column must be rejected") + .to_string(); + assert!(error.contains("not a tag column"), "{error}"); +} diff --git a/src/mito2/AGENTS.md b/src/mito2/AGENTS.md index f70aa622a27..95b9eddae07 100644 --- a/src/mito2/AGENTS.md +++ b/src/mito2/AGENTS.md @@ -26,7 +26,7 @@ snapshot isolation). It implements the `RegionEngine` trait from `store-api`. | `compaction` | `src/mito2/src/compaction/` | Compaction scheduler (`scheduler.rs` + `scheduler/`), TWCS picker, strict-window manual picker, compactor, memory control | | `access_layer` | `src/mito2/src/access_layer.rs` | SST read/write over the object store | | `sst` | `src/mito2/src/sst/` | Parquet format, file metadata, index layout | -| `series_index` | `src/mito2/src/series_index/` | Series index writer/searcher, bucket planning, catalogs, worker-owned reconciliation (`maintenance.rs`), shared approximate usage (`task.rs`), catalogs, and index lifecycle | +| `series_index` | `src/mito2/src/series_index/` | Format interfaces (`format.rs`), index writer/searcher, per-SST samples artifacts (`artifact.rs`), bucket planning, catalogs, reconciliation (`maintenance.rs`), and deferred purge | | `read` | `src/mito2/src/read/` | `ScanRegion`, merge, dedup, projection, streaming | | `manifest` | `src/mito2/src/manifest/` | `RegionManifestManager`, manifest actions/edits | | `cache` | `src/mito2/src/cache.rs` | Write/file/page caches | @@ -66,6 +66,7 @@ filtered `RecordBatch` stream. and defer new builds when the shared estimate is full. Cleanup still prunes obsolete range entries and expires series indexes to reclaim published usage. Concurrent builds may overshoot; closed-region files are not counted. Series handles and the SST purger own file deletion. +- **Series format PoC**: samples are rebuildable local SST sidecars. Optional writer interfaces (`format.rs`) integrate with reconciliation, catalog publication, and deferred purge. Defaults retain the original Parquet writer and disable samples. Prepared sources, read APIs, and concrete new formats are deferred; ordinary scans only use Parquet index artifacts. - **Series-index coverage** (`series_index/catalog.rs`): `SeriesIndexEntry` stores compaction-window width and SST summaries keyed by aligned start in both catalogs and Parquet footers. - **Request types** (`request.rs`): usually tied to proto definitions consumed by `datanode`. diff --git a/src/mito2/src/engine/scan_test.rs b/src/mito2/src/engine/scan_test.rs index c0b155f7311..be9a5f207ae 100644 --- a/src/mito2/src/engine/scan_test.rs +++ b/src/mito2/src/engine/scan_test.rs @@ -1549,6 +1549,7 @@ async fn check_two_phase_series_scan( let store = region.series_index_store.clone().unwrap(); let sequence = region.flushed_sequence(); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: Timestamp::new_millisecond(0), @@ -5117,7 +5118,7 @@ async fn test_mito_handle_query_raw_series_candidate_projection_and_mvcc() { }; let native_plan = Arc::new(RegionScanExec::new(native_scanner, request.clone(), None).unwrap()); let native_plan = native_plan - .try_to_series_scan(native_source.clone()) + .try_to_series_scan(native_source) .unwrap() .expect("Mito raw-series capability"); let native_schema = native_plan.series_schema().clone(); @@ -5132,8 +5133,7 @@ async fn test_mito_handle_query_raw_series_candidate_projection_and_mvcc() { frame_ordering, "the legacy producer must declare key-then-time frames" ); - let unpacked = - unpack_raw_series(native_frames, native_schema, native_source, frame_ordering).unwrap(); + let unpacked = unpack_raw_series(native_frames, Some(native_schema)).unwrap(); let native_schema = unpacked.schema().clone(); let mut native = unpacked.try_collect::>().await.unwrap(); assert_eq!( @@ -5369,14 +5369,14 @@ async fn test_two_phase_native_raw_stream_declares_contiguous_frames() { let schema = schema.expect("the scan must emit at least one partition stream"); let packed = pack_raw_series(Box::pin(ChainedRawSeriesStream::new( Arc::clone(&schema), - source.clone(), + source, SeriesOrdering::Contiguous, chunks, ))) .unwrap(); let ordering = raw_series_frame_ordering(packed.schema().as_ref()).unwrap(); assert_eq!(SeriesOrdering::Contiguous, ordering); - let validated = unpack_raw_series(packed, schema, source, ordering) + let validated = unpack_raw_series(packed, Some(schema)) .unwrap() .try_collect::>() .await @@ -5719,18 +5719,12 @@ async fn test_two_phase_raw_stream_stays_contiguous_across_disjoint_partition_as // not final. let packed = pack_raw_series(Box::pin(ChainedRawSeriesStream::new( Arc::clone(&schema), - source.clone(), + source, SeriesOrdering::Contiguous, complete.clone(), ))) .unwrap(); - let unpacked = unpack_raw_series( - packed, - Arc::clone(&schema), - source, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let unpacked = unpack_raw_series(packed, Some(schema)).unwrap(); let validated = unpacked.try_collect::>().await.unwrap(); assert_eq!( complete_rows, @@ -6063,18 +6057,12 @@ async fn test_two_phase_raw_same_partition_multi_round_assignment_stays_contiguo // the chunks this reader produced, including the chunks the second assignment added. let packed = pack_raw_series(Box::pin(ChainedRawSeriesStream::new( Arc::clone(&schema), - source.clone(), + source, SeriesOrdering::Contiguous, chunks.clone(), ))) .unwrap(); - let unpacked = unpack_raw_series( - packed, - Arc::clone(&schema), - source, - SeriesOrdering::Contiguous, - ) - .unwrap(); + let unpacked = unpack_raw_series(packed, Some(Arc::clone(&schema))).unwrap(); let validated = unpacked.try_collect::>().await.unwrap(); assert_eq!( stream_rows, @@ -6199,6 +6187,8 @@ fn primary_key_label_source( struct ChainedRawSeriesStream { schema: Arc, label_source: store_api::region_engine::RawLabelSource, + /// The description this stream declares, derived from `label_source` once, like a producer. + label_decode_spec: Option, ordering: datatypes::series::SeriesOrdering, chunks: std::vec::IntoIter, } @@ -6210,9 +6200,11 @@ impl ChainedRawSeriesStream { ordering: datatypes::series::SeriesOrdering, chunks: Vec, ) -> Self { + let label_decode_spec = common_query::series_frame::label_decode_spec(&label_source); Self { schema, label_source, + label_decode_spec, ordering, chunks: chunks.into_iter(), } @@ -6239,11 +6231,221 @@ impl store_api::region_engine::RawSeriesStream for ChainedRawSeriesStream { &self.label_source } + fn label_decode_spec(&self) -> Option<&datatypes::series::frame::LabelDecodeSpec> { + self.label_decode_spec.as_ref() + } + fn ordering(&self) -> datatypes::series::SeriesOrdering { self.ordering } } +/// Decodes each chunk's primary key through `description`, as a consumer holding only the stream +/// would, and asserts none of the chunks carries inline labels. +fn decode_declared_labels( + description: &datatypes::series::frame::LabelDecodeSpec, + chunks: &[datatypes::series::raw::RawSeriesChunk], +) -> Vec { + use mito_codec::labels::{DecodedLabels, LabelProjection, PrimaryKeyLabelDecoder}; + + let mut decoder = + PrimaryKeyLabelDecoder::from_decode_spec(description, LabelProjection::All).unwrap(); + chunks + .iter() + .map(|chunk| { + assert!( + chunk.metric.is_none(), + "a key-encoded chunk leaves its labels undecoded" + ); + match decoder.decode(chunk.primary_key.as_slice()).unwrap() { + DecodedLabels::Complete(metric) => metric, + DecodedLabels::Projected(_) => { + panic!("decoding every label must yield complete labels") + } + } + }) + .collect() +} + +/// Every key-encoded raw series stream declares its own label decode description before the first +/// poll, and that declaration is the one derived from the binding the scan read under. Dense and +/// sparse producers are both covered, plus a stream that ends without producing a chunk, so the +/// producer and the consumer are exercised against the same declaration. +#[tokio::test] +async fn test_mito_raw_series_stream_declares_its_label_decode_description() { + use store_api::region_engine::QueryScanContext; + + // Dense encoding: one flushed series under the default region layout. + let mut env = TestEnv::with_prefix("test_mito_raw_series_self_describing_dense").await; + let engine = env.create_engine(MitoConfig::default()).await; + let region_id = RegionId::new(1, 1); + let request = CreateRequestBuilder::new().build(); + let schema = test_util::rows_schema(&request); + engine + .handle_request(region_id, RegionRequest::Create(request)) + .await + .unwrap(); + test_util::put_rows( + &engine, + region_id, + Rows { + schema, + rows: test_util::build_rows_for_key("host-a", 0, 2, 0), + }, + ) + .await; + test_util::flush_region(&engine, region_id, None).await; + + let request = ScanRequest { + distribution: Some(TimeSeriesDistribution::PerSeries), + preserve_pk_dictionary_encoding: true, + ..Default::default() + }; + let mut scanner = engine.handle_query(region_id, request).await.unwrap(); + let source = primary_key_label_source(&scanner.metadata()); + let expected = common_query::series_frame::label_decode_spec(&source) + .expect("a primary-key binding describes its key"); + let ranges = scanner.properties().partitions.clone(); + scanner + .prepare(PrepareRequest { + ranges: Some(ranges.clone()), + target_partitions: Some(ranges.len()), + ..Default::default() + }) + .unwrap(); + let metrics = ExecutionPlanMetricsSet::default(); + let mut chunks = Vec::new(); + for partition in 0..ranges.len() { + let stream = scanner + .scan_series_partition(&QueryScanContext::default(), &metrics, partition, &source) + .unwrap() + .expect("a dense PerSeries scan produces raw series"); + // The producer states its layout up front, before anything is polled. + assert_eq!( + Some(&expected), + stream.label_decode_spec(), + "dense partition {partition} must declare its label decode description before polling" + ); + chunks.extend(stream.try_collect::>().await.unwrap()); + } + assert_eq!( + 2, + chunks.iter().map(|chunk| chunk.time.len()).sum::() + ); + let dense_metrics = decode_declared_labels(&expected, &chunks); + assert_eq!(dense_metrics.len(), chunks.len()); + for metric in &dense_metrics { + assert_eq!(metric.name(), Some("fixture_metric")); + assert_eq!(metric.get("tag_0"), Some("host-a")); + } + + // A statically empty stream declares the same layout while producing no chunk at all. + let empty_request = ScanRequest { + filters: vec![col("field_0").gt(lit(1.0e9_f64))], + distribution: Some(TimeSeriesDistribution::PerSeries), + preserve_pk_dictionary_encoding: true, + ..Default::default() + }; + let mut empty_scanner = engine.handle_query(region_id, empty_request).await.unwrap(); + let empty_source = primary_key_label_source(&empty_scanner.metadata()); + let empty_expected = common_query::series_frame::label_decode_spec(&empty_source) + .expect("a primary-key binding describes its key"); + let empty_ranges = empty_scanner.properties().partitions.clone(); + assert!( + !empty_ranges.is_empty(), + "a filtered PerSeries scan still assigns its pinned input to a partition" + ); + empty_scanner + .prepare(PrepareRequest { + ranges: Some(empty_ranges.clone()), + target_partitions: Some(empty_ranges.len()), + ..Default::default() + }) + .unwrap(); + let mut empty_chunks = Vec::new(); + for partition in 0..empty_ranges.len() { + let stream = empty_scanner + .scan_series_partition( + &QueryScanContext::default(), + &metrics, + partition, + &empty_source, + ) + .unwrap() + .expect("a statically empty PerSeries scan still produces a raw series stream"); + assert_eq!( + Some(&empty_expected), + stream.label_decode_spec(), + "an empty partition {partition} must still declare its layout before polling" + ); + empty_chunks.extend(stream.try_collect::>().await.unwrap()); + } + assert!( + empty_chunks.is_empty(), + "the filtered-out stream produced no chunk" + ); + + // Sparse encoding: the two-phase producer encodes table id and TSID into the key too. + let mut env = TestEnv::with_prefix("test_mito_raw_series_self_describing_sparse").await; + let engine = env + .create_engine(MitoConfig { + experimental_series_scan_v2: true, + ..Default::default() + }) + .await; + let region_id = RegionId::new(1, 1); + let (metadata, row_schema) = create_two_phase_sparse_region(&engine, region_id).await; + let key = new_sparse_primary_key(&["host-a", "zone-1"], &metadata, 10, 0); + put_sparse_rows( + &engine, + region_id, + &row_schema, + vec![(key.clone(), 10, 1), (key, 20, 2)], + ) + .await; + let request = ScanRequest { + distribution: Some(TimeSeriesDistribution::PerSeries), + preserve_pk_dictionary_encoding: true, + ..Default::default() + }; + let mut scanner = engine.handle_query(region_id, request).await.unwrap(); + let source = primary_key_label_source(&scanner.metadata()); + let expected = common_query::series_frame::label_decode_spec(&source) + .expect("a primary-key binding describes its key"); + let ranges = scanner.properties().partitions.clone(); + scanner + .prepare(PrepareRequest { + ranges: Some(ranges.clone()), + target_partitions: Some(ranges.len()), + ..Default::default() + }) + .unwrap(); + let mut chunks = Vec::new(); + for partition in 0..ranges.len() { + let stream = scanner + .scan_series_partition(&QueryScanContext::default(), &metrics, partition, &source) + .unwrap() + .expect("a sparse PerSeries scan produces raw series"); + assert_eq!( + Some(&expected), + stream.label_decode_spec(), + "sparse partition {partition} must declare its label decode description before polling" + ); + chunks.extend(stream.try_collect::>().await.unwrap()); + } + assert_eq!( + 2, + chunks.iter().map(|chunk| chunk.time.len()).sum::() + ); + let metrics_seen = decode_declared_labels(&expected, &chunks); + assert_eq!(metrics_seen.len(), chunks.len()); + for metric in &metrics_seen { + assert_eq!(metric.name(), Some("fixture_metric")); + assert_eq!(metric.get("tag_0"), Some("host-a")); + assert_eq!(metric.get("tag_1"), Some("zone-1")); + } +} + #[tokio::test] async fn test_mito_raw_series_int64_predicate_uses_original_precision() { use datatypes::series::samples::SampleRef; @@ -6387,7 +6589,7 @@ async fn test_mito_raw_series_int64_predicate_uses_original_precision() { }]), }; let native_plan = Arc::new(RegionScanExec::new(native_scanner, native_request, None).unwrap()) - .try_to_series_scan(native_source.clone()) + .try_to_series_scan(native_source) .unwrap() .expect("typed int64 gate is supported"); let native_schema = native_plan.series_schema().clone(); @@ -6401,7 +6603,7 @@ async fn test_mito_raw_series_int64_predicate_uses_original_precision() { ordering, "the legacy producer must declare key-then-time frames" ); - let chunks = unpack_raw_series(frames, native_schema, native_source, ordering) + let chunks = unpack_raw_series(frames, Some(native_schema)) .unwrap() .try_collect::>() .await @@ -6446,7 +6648,7 @@ async fn test_mito_raw_series_int64_predicate_uses_original_precision() { }]), }; let empty_plan = Arc::new(RegionScanExec::new(empty_scanner, empty_request, None).unwrap()) - .try_to_series_scan(empty_source.clone()) + .try_to_series_scan(empty_source) .unwrap() .expect("statically empty scan still has native capability"); let empty_schema = empty_plan.series_schema().clone(); @@ -6459,7 +6661,7 @@ async fn test_mito_raw_series_int64_predicate_uses_original_precision() { empty_ordering, "a statically empty legacy scan still declares key-then-time frames" ); - let empty_chunks = unpack_raw_series(empty_frames, empty_schema, empty_source, empty_ordering) + let empty_chunks = unpack_raw_series(empty_frames, Some(empty_schema)) .unwrap() .try_collect::>() .await diff --git a/src/mito2/src/read/series_candidate.rs b/src/mito2/src/read/series_candidate.rs index 3ba5512a7bd..82210e0ffa7 100644 --- a/src/mito2/src/read/series_candidate.rs +++ b/src/mito2/src/read/series_candidate.rs @@ -243,6 +243,10 @@ impl SeriesIndexCoverage { .version .series_indexes .values() + // Unimplemented formats cannot replace authoritative SST candidates. + .filter(|index| { + index.entry().format() == crate::series_index::artifact::ArtifactFormat::Parquet + }) .map(|index| { let files: HashSet<_> = uncovered .iter() @@ -778,6 +782,7 @@ mod tests { )); let store = env.access_layer.object_store().clone(); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: Timestamp::new_millisecond(0), @@ -964,6 +969,48 @@ mod tests { } } + #[tokio::test] + async fn index_merge_releases_permits_before_yielding() { + let (_env, scanner, _pruner) = indexed_scanner().await; + let semaphore = Arc::new(Semaphore::new(1)); + let context = scanner.stream_ctx.input.series_index.clone().unwrap(); + let streams = (0..3) + .map(|_| { + index_primary_key_stream( + scanner.stream_ctx.clone(), + context.clone(), + scanner.coverage.indexes[0].clone(), + semaphore.clone(), + ) + }) + .collect(); + let merged = merge_primary_key_streams( + streams, + scanner.memory_pool.clone(), + &scanner.metrics_set, + 0, + "permit-regression", + ) + .unwrap(); + let series = + decode_metric_series(merged, scanner.stream_ctx.input.region_metadata().clone()) + .unwrap(); + let batches = tokio::time::timeout( + std::time::Duration::from_secs(10), + series.try_collect::>(), + ) + .await + .expect("index streams must not hold permits across yield") + .unwrap(); + assert_eq!( + (0..1001) + .map(|tsid| MetricSeriesId { table_id: 1, tsid }) + .collect::>(), + batches.into_iter().flatten().collect::>() + ); + assert_eq!(1, semaphore.available_permits()); + } + #[tokio::test] async fn index_read_failure_after_output_is_propagated() { let (_env, scanner, _pruner) = indexed_scanner().await; diff --git a/src/mito2/src/read/series_scan/raw.rs b/src/mito2/src/read/series_scan/raw.rs index 4adac4263ae..00d6867921f 100644 --- a/src/mito2/src/read/series_scan/raw.rs +++ b/src/mito2/src/read/series_scan/raw.rs @@ -25,6 +25,7 @@ use common_query::error::Error as QueryError; use common_query::native_histogram::{ histogram_samples_from_arrow, is_native_histogram_value_type, }; +use common_query::series_frame::label_decode_spec; use common_recordbatch::filter; use datafusion::physical_plan::PhysicalExpr; use datatypes::arrow::array::{ @@ -37,6 +38,7 @@ use datatypes::arrow::datatypes::DataType; use datatypes::arrow::record_batch::RecordBatch; use datatypes::prelude::ConcreteDataType; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{Samples, SeriesFields, SeriesSchema}; use futures::stream::BoxStream; @@ -128,6 +130,13 @@ fn chunk( struct RawStream { schema: Arc, label_source: RawLabelSource, + /// The label decode description this stream declares, derived from `label_source` once. + /// + /// Key-encoded chunks leave `metric` as `None`, so a consumer that holds only the stream needs + /// the description to decode them; deriving it here makes the producer self-describing instead + /// of leaving the derivation to whoever packs the chunks. An invalid binding is cached as + /// derived and rejected by the decoder downstream, never silently turned into `None`. + label_decode_spec: Option, ordering: SeriesOrdering, inner: BoxStream<'static, Result>, } @@ -146,6 +155,11 @@ impl RawSeriesStream for RawStream { fn label_source(&self) -> &RawLabelSource { &self.label_source } + /// Returns the description derived from this stream's binding before the first poll; chunks + /// carry key-encoded labels, so the description is what decoding needs. + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.label_decode_spec.as_ref() + } fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -161,6 +175,7 @@ pub(crate) fn make_stream( ordering: SeriesOrdering, ) -> SendableRawSeriesStream { let output_schema = schema.clone(); + let label_decode_spec = label_decode_spec(&label_source); let stream = async_stream::stream! { let mut input = inner; let mut pending: Option = None; @@ -267,6 +282,7 @@ pub(crate) fn make_stream( Box::pin(RawStream { schema, label_source, + label_decode_spec, ordering, inner: Box::pin(stream), }) diff --git a/src/mito2/src/series_index.rs b/src/mito2/src/series_index.rs index 3aa70256709..585120628e8 100644 --- a/src/mito2/src/series_index.rs +++ b/src/mito2/src/series_index.rs @@ -16,9 +16,13 @@ //! //! Under development. Index files are currently stored on the local filesystem. +pub(crate) mod artifact; mod bucket; mod builder; mod catalog; +pub(crate) mod format; +#[cfg(test)] +mod format_tests; mod maintenance; mod purger; mod searcher; diff --git a/src/mito2/src/series_index/artifact.rs b/src/mito2/src/series_index/artifact.rs new file mode 100644 index 00000000000..e1412920f6f --- /dev/null +++ b/src/mito2/src/series_index/artifact.rs @@ -0,0 +1,93 @@ +// 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. + +//! Immutable, potentially multi-object format outputs and reader-owned lifetimes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::{Deserialize, Serialize}; +use store_api::storage::FileId; + +use crate::series_index::purger::IndexFilePurger; +use crate::sst::file::RegionFileId; + +/// Persisted artifact identifiers. Concrete codecs are added with their implementations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum ArtifactFormat { + #[serde(rename = "parquet")] + Parquet, + #[cfg(test)] + #[serde(rename = "fake-samples")] + FakeSamples, + #[cfg(test)] + #[serde(rename = "fake-index")] + FakeIndex, +} + +/// Identifies the exact bytes produced by one backend build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ArtifactMetadata { + pub(crate) format: ArtifactFormat, + pub(crate) generation: FileId, + /// Every object owned by this build, including metadata and auxiliary files. + pub(crate) objects: Vec, + pub(crate) file_size: u64, +} + +/// One samples artifact derived from an immutable Parquet SST. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SamplesEntry { + pub(crate) file_id: FileId, + pub(crate) artifact: ArtifactMetadata, +} + +#[derive(Debug, Clone)] +pub(crate) struct SamplesFileHandle(Arc); + +#[derive(Debug)] +struct SamplesFileInner { + file_id: RegionFileId, + entry: SamplesEntry, + retired: AtomicBool, + purger: IndexFilePurger, +} + +impl SamplesFileHandle { + pub(crate) fn new(file_id: RegionFileId, entry: SamplesEntry, purger: IndexFilePurger) -> Self { + Self(Arc::new(SamplesFileInner { + file_id, + entry, + retired: AtomicBool::new(false), + purger, + })) + } + + pub(crate) fn entry(&self) -> &SamplesEntry { + &self.0.entry + } + + pub(crate) fn mark_deleted(&self) { + self.0.retired.store(true, Ordering::Release); + } +} + +impl Drop for SamplesFileInner { + fn drop(&mut self) { + if self.retired.load(Ordering::Acquire) { + self.purger + .purge_objects(self.file_id, self.entry.artifact.objects.clone()); + } + } +} diff --git a/src/mito2/src/series_index/bucket.rs b/src/mito2/src/series_index/bucket.rs index 446948838f6..470caeadd3b 100644 --- a/src/mito2/src/series_index/bucket.rs +++ b/src/mito2/src/series_index/bucket.rs @@ -334,6 +334,7 @@ impl SeriesBucket { .filter_map(|file| file.meta_ref().sequence.map(|sequence| sequence.get())) .min()?; Some(SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: self.start, diff --git a/src/mito2/src/series_index/builder.rs b/src/mito2/src/series_index/builder.rs index 63a51badaeb..2a9db9503e3 100644 --- a/src/mito2/src/series_index/builder.rs +++ b/src/mito2/src/series_index/builder.rs @@ -35,6 +35,7 @@ use crate::series_index::bucket::SeriesBucket; use crate::series_index::catalog::{ RangeIndexEntry, SeriesIndexEntry, range_index_path, series_index_path, series_metadata, }; +use crate::series_index::format::SeriesIndexFormat; use crate::series_index::purger::{IndexFilePurger, IndexFileType, file_operation}; use crate::series_index::version::SeriesIndexFileHandle; use crate::series_index::{SeriesIndexWriter, SeriesIndexWriterOptions}; @@ -130,6 +131,7 @@ pub(crate) async fn build_series_index( version: &VersionRef, bucket: &SeriesBucket, entry: &SeriesIndexEntry, + backend: Option<&dyn SeriesIndexFormat>, purger: &IndexFilePurger, ) -> Result { let mut sources = Vec::::new(); @@ -180,6 +182,35 @@ pub(crate) async fn build_series_index( ) }; // TODO(yingwen): Deduplicate update-mode rows before series indexes are used by queries. + if let Some(backend) = backend { + let mut writer = backend + .writer(version.metadata.clone(), store.clone(), entry) + .await?; + let result: Result<()> = async { + while let Some(batch) = visible.try_next().await? { + writer.write(&batch).await?; + } + Ok(()) + } + .await; + if let Err(error) = result { + if let Err(cleanup_error) = writer.abort().await { + warn!(cleanup_error; "Failed to abort series-index artifact build"); + } + return Err(error); + } + let artifact = writer.finish().await?; + file_operation(IndexFileType::Series, "build", "success"); + return Ok(SeriesIndexFileHandle::new( + region.region_id, + SeriesIndexEntry { + file_size: artifact.file_size, + artifact: Some(artifact), + ..entry.clone() + }, + purger.clone(), + )); + } let path = series_index_path(region.region_id, entry.index_uuid); let mut writer = SeriesIndexWriter::try_new( version.metadata.clone(), @@ -214,6 +245,79 @@ pub(crate) async fn build_series_index( )) } +/// Converts one immutable SST into an independently rebuildable samples artifact. +pub(crate) async fn build_samples( + store: &ObjectStore, + region: &MitoRegionRef, + version: &VersionRef, + file: FileHandle, + backend: &dyn crate::series_index::format::SamplesFormat, + purger: &IndexFilePurger, +) -> Result> { + use crate::series_index::artifact::{SamplesEntry, SamplesFileHandle}; + let file_id = file.file_id(); + let Some((context, mut selection)) = region + .access_layer + .read_sst(file) + .build_reader_input(&mut ReaderMetrics::default()) + .await? + else { + return Ok(None); + }; + let context = Arc::new(context); + let mapper = FlatProjectionMapper::new( + &version.metadata, + 0..version.metadata.column_metadatas.len(), + )?; + let compat = FlatCompatBatch::try_new(&mapper, context.read_format(), false)?; + let generation = store_api::storage::FileId::random(); + let prefix = format!( + "{}/samples/{}/{generation}/", + region.region_id.as_u64(), + file_id.file_id() + ); + let mut writer = backend + .writer(version.metadata.clone(), store.clone(), &prefix, generation) + .await?; + let result: Result<()> = async { + while let Some((row_group, selection)) = selection.pop_first() { + let reader = context + .reader_builder() + .build(context.build_context(row_group, Some(selection), None)) + .await?; + let mut reader = FlatPruneReader::new_with_row_group_reader( + context.clone(), + FlatRowGroupReader::new(context.clone(), reader), + context.pre_filter_mode().skip_fields(), + ); + while let Some(batch) = reader.next_batch().await? { + let batch = match &compat { + Some(compat) => compat.compat(batch)?, + None => batch, + }; + writer.write(&batch).await?; + } + } + Ok(()) + } + .await; + if let Err(error) = result { + if let Err(cleanup) = writer.abort().await { + warn!(cleanup; "Failed to abort samples build"); + } + return Err(error); + } + let artifact = writer.finish().await?; + Ok(Some(SamplesFileHandle::new( + file_id, + SamplesEntry { + file_id: file_id.file_id(), + artifact, + }, + purger.clone(), + ))) +} + #[cfg(test)] mod tests { use std::collections::{BTreeMap, HashMap}; @@ -228,7 +332,8 @@ mod tests { use super::*; use crate::series_index::bucket::{group_files_into_series_buckets, plan_series_indexes}; use crate::series_index::catalog::{ - SeriesIndexCatalog, load_version_control, series_catalog_path, store_catalog, + SeriesIndexCatalog, load_version_control, series_catalog_path, series_index_path, + store_catalog, }; use crate::series_index::purger::series_index_channel; use crate::series_index::tests::prepare_region; @@ -321,6 +426,7 @@ mod tests { &version, &bucket, &entry, + None, &purger, ) .await; @@ -471,7 +577,7 @@ mod tests { .unwrap() .unwrap(); } - let _handle = build_series_index(&store, ®ion, &version, &bucket, &entry, &purger) + let _handle = build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger) .await .unwrap(); let bytes = store @@ -540,7 +646,7 @@ mod tests { }; completed.push(entry.file_id); } - build_series_index(&store, ®ion, &version, &bucket, &entry, &purger) + build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger) .await .map(Some) } @@ -746,7 +852,7 @@ mod tests { .map(|_| ()) } else { let (purger, _receiver) = series_index_channel(store.clone()); - build_series_index(&store, ®ion, &version, &bucket, &entry, &purger) + build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger) .await .map(|_| ()) }; diff --git a/src/mito2/src/series_index/catalog.rs b/src/mito2/src/series_index/catalog.rs index 95cf87f83d3..66355ace359 100644 --- a/src/mito2/src/series_index/catalog.rs +++ b/src/mito2/src/series_index/catalog.rs @@ -33,6 +33,7 @@ use crate::series_index::version::{ pub(crate) use crate::sst::range_index::range_index_path; const SERIES_DIR: &str = "series"; +const SAMPLES_CATALOG: &str = "samples.json"; const RANGE_CATALOG: &str = "range-index.json"; const SERIES_CATALOG: &str = "series-index.json"; const SERIES_METADATA_KEY: &str = "greptime.series_index"; @@ -60,6 +61,8 @@ pub(crate) struct WindowSequence { /// relies on this complete-coverage contract, not on `source_file_ids`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct SeriesIndexEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) artifact: Option, /// Completed size in the catalog; zero in the footer written before completion. pub(crate) file_size: u64, pub(crate) index_uuid: FileId, @@ -83,6 +86,14 @@ pub(crate) struct SeriesIndexEntry { } impl SeriesIndexEntry { + /// Entries written before pluggable formats describe the original Parquet layout. + pub(crate) fn format(&self) -> crate::series_index::artifact::ArtifactFormat { + self.artifact.as_ref().map_or( + crate::series_index::artifact::ArtifactFormat::Parquet, + |artifact| artifact.format, + ) + } + /// Whether this index completely covers an SST in the query's sequence domain. pub(crate) fn covers_file( &self, @@ -116,6 +127,18 @@ pub(crate) struct RangeIndexCatalog { pub(crate) indexes: Vec, } +#[derive(Debug, Default, Serialize, Deserialize)] +pub(crate) struct SamplesCatalog { + pub(crate) entries: Vec, + /// Once samples are enabled this single catalog commits all artifact references together. + pub(crate) series: Vec, + pub(crate) ranges: Vec, +} + +pub(crate) fn samples_catalog_path(region_id: RegionId) -> String { + format!("{}/{SAMPLES_CATALOG}", region_id.as_u64()) +} + pub(crate) fn range_catalog_path(region_id: RegionId) -> String { format!("{}/{RANGE_CATALOG}", region_id.as_u64()) } @@ -172,6 +195,7 @@ where pub(crate) async fn delete_catalogs(store: &ObjectStore, region_id: RegionId) { for path in [ series_catalog_path(region_id), + samples_catalog_path(region_id), range_catalog_path(region_id), ] { if let Err(error) = store.delete(&path).await @@ -194,14 +218,17 @@ pub(crate) async fn load_version_control( let series = load_catalog::(store, &series_catalog_path(region_id)) .await .unwrap_or_default(); - let version = SeriesIndexVersion::new( - range - .indexes + let samples = load_catalog::(store, &samples_catalog_path(region_id)).await; + let (ranges, indexes, sample_entries) = match samples { + Some(catalog) => (catalog.ranges, catalog.series, catalog.entries), + None => (range.indexes, series.indexes, Vec::new()), + }; + let mut version = SeriesIndexVersion::new( + ranges .into_iter() .map(|entry| (entry.file_id, entry)) .collect(), - series - .indexes + indexes .into_iter() .map(|entry| { ( @@ -211,6 +238,17 @@ pub(crate) async fn load_version_control( }) .collect(), ); + for entry in sample_entries { + let id = entry.file_id; + version.samples.insert( + id, + crate::series_index::artifact::SamplesFileHandle::new( + crate::sst::file::RegionFileId::new(region_id, id), + entry, + purger.clone(), + ), + ); + } let control = SeriesIndexVersionControl::default(); control.publish(std::sync::Arc::new(version)); control @@ -258,10 +296,37 @@ mod tests { } } + #[test] + fn legacy_series_catalog_fixture() { + use crate::series_index::artifact::ArtifactFormat; + let json = include_str!( + "../../../../tests/compatibility/fixtures/series_index/legacy_catalog.json" + ); + let catalog: SeriesIndexCatalog = serde_json::from_str(json).unwrap(); + assert_eq!(1, catalog.indexes.len()); + let entry = &catalog.indexes[0]; + assert!(entry.artifact.is_none()); + assert_eq!(ArtifactFormat::Parquet, entry.format()); + assert_eq!( + serde_json::from_str::(json).unwrap(), + serde_json::to_value(&catalog).unwrap() + ); + for (format, name) in [ + (ArtifactFormat::Parquet, "parquet"), + (ArtifactFormat::FakeSamples, "fake-samples"), + (ArtifactFormat::FakeIndex, "fake-index"), + ] { + let encoded = serde_json::to_value(format).unwrap(); + assert_eq!(name, encoded.as_str().unwrap()); + assert_eq!(format, serde_json::from_value(encoded).unwrap()); + } + } + #[test] fn coverage_uses_exclusive_time_end_and_inclusive_file_sequences() { let region_id = RegionId::new(1, 1); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: Timestamp::new_second(1), @@ -359,6 +424,7 @@ mod tests { let store = ObjectStore::new(Memory::default()).unwrap(); let region_id = RegionId::new(1, 1); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: Timestamp::new_second(0), diff --git a/src/mito2/src/series_index/format.rs b/src/mito2/src/series_index/format.rs new file mode 100644 index 00000000000..b59933f719a --- /dev/null +++ b/src/mito2/src/series_index/format.rs @@ -0,0 +1,72 @@ +// 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. + +//! Storage-format boundaries. Encodings stay outside reconciliation and source scheduling. + +use std::fmt::Debug; +use std::sync::Arc; + +use async_trait::async_trait; +use datatypes::arrow::record_batch::RecordBatch; +use object_store::ObjectStore; +use store_api::metadata::RegionMetadataRef; +use store_api::storage::FileId; + +use crate::error::Result; +use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata}; +use crate::series_index::catalog::SeriesIndexEntry; + +/// Writers own partial outputs until finish succeeds; abort removes every partial object. +/// A failed finish must clean up its output before returning; implementations also own +/// cleanup on cancellation while a writer is live. +#[async_trait] +pub(crate) trait FormatWriter: Send { + async fn write(&mut self, batch: &RecordBatch) -> Result<()>; + async fn finish(self: Box) -> Result; + async fn abort(self: Box) -> Result<()>; +} + +/// Builds series-index artifacts; read interfaces are deferred. +#[async_trait] +pub(crate) trait SeriesIndexFormat: Debug + Send + Sync { + fn format(&self) -> ArtifactFormat; + async fn writer( + &self, + metadata: RegionMetadataRef, + store: ObjectStore, + entry: &SeriesIndexEntry, + ) -> Result>; +} + +/// Builds samples artifacts; read interfaces are deferred. +#[async_trait] +pub(crate) trait SamplesFormat: Debug + Send + Sync { + fn format(&self) -> ArtifactFormat; + /// The caller supplies a unique prefix. Finish reports all objects owned below it. + async fn writer( + &self, + metadata: RegionMetadataRef, + store: ObjectStore, + prefix: &str, + generation: FileId, + ) -> Result>; +} + +/// Optional writers, captured once per reconciliation pass. +/// Without an index backend, builds use the existing Parquet writer. +#[derive(Debug, Clone, Default)] +pub(crate) struct SeriesFormats { + pub(crate) index: Option>, + pub(crate) samples: Option>, +} diff --git a/src/mito2/src/series_index/format_tests.rs b/src/mito2/src/series_index/format_tests.rs new file mode 100644 index 00000000000..a7e0c86069d --- /dev/null +++ b/src/mito2/src/series_index/format_tests.rs @@ -0,0 +1,442 @@ +// 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. + +//! Artifact lifecycle tests; fake formats have no codec. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use datatypes::arrow::record_batch::RecordBatch; +use futures::TryStreamExt; +use object_store::ObjectStore; +use object_store::layers::mock::{self, MockLayerBuilder, oio}; +use snafu::ResultExt; +use store_api::metadata::RegionMetadataRef; +use store_api::region_engine::RegionEngine; +use store_api::storage::FileId; + +use crate::config::MitoConfig; +use crate::error::{OpenDalSnafu, Result, UnexpectedSnafu}; +use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata}; +use crate::series_index::catalog::{SeriesIndexEntry, load_version_control}; +use crate::series_index::format::{FormatWriter, SamplesFormat, SeriesFormats, SeriesIndexFormat}; +use crate::series_index::maintenance::reconcile_series_indexes; +use crate::series_index::purger::series_index_channel; +use crate::series_index::tests::prepare_region_with_config; +use crate::test_util::TestEnv; + +#[derive(Debug, Default)] +struct FakeSamples { + fail: Arc, +} + +struct FakeWriter { + store: ObjectStore, + artifact: ArtifactMetadata, + fail: Arc, +} + +#[async_trait] +impl FormatWriter for FakeWriter { + async fn write(&mut self, _batch: &RecordBatch) -> Result<()> { + if self.fail.load(Ordering::Relaxed) { + return UnexpectedSnafu { + reason: "injected samples write failure", + } + .fail(); + } + Ok(()) + } + async fn finish(self: Box) -> Result { + Ok(self.artifact) + } + async fn abort(self: Box) -> Result<()> { + for path in &self.artifact.objects { + self.store.delete(path).await.context(OpenDalSnafu)?; + } + Ok(()) + } +} + +#[async_trait] +impl SamplesFormat for FakeSamples { + fn format(&self) -> ArtifactFormat { + ArtifactFormat::FakeSamples + } + async fn writer( + &self, + _metadata: RegionMetadataRef, + store: ObjectStore, + prefix: &str, + generation: FileId, + ) -> Result> { + let objects = vec![format!("{prefix}data"), format!("{prefix}directory")]; + for path in &objects { + store.write(path, "fake").await.context(OpenDalSnafu)?; + } + Ok(Box::new(FakeWriter { + store, + artifact: ArtifactMetadata { + format: self.format(), + generation, + objects, + file_size: 8, + }, + fail: self.fail.clone(), + })) + } +} + +#[tokio::test] +async fn format_skeleton_build_reopen_and_retire() { + let mut env = TestEnv::with_prefix("format-skeleton").await; + let (engine, region) = prepare_region_with_config( + &mut env, + &[1000, 2000], + MitoConfig { + experimental_enable_series_index: true, + experimental_series_index_maintenance_interval: Duration::from_secs(3600), + min_compaction_interval: Duration::from_secs(3600), + ..Default::default() + }, + ) + .await; + let fake = Arc::new(FakeSamples::default()); + *region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats { + samples: Some(fake.clone()), + ..Default::default() + }); + let store = region.series_index_store.clone().unwrap(); + let (purger, mut purges) = series_index_channel(store.clone()); + let stats = reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + .await + .unwrap(); + assert_eq!(2, stats.built_samples); + let snapshot = region.series_index_version(); + assert_eq!(2, snapshot.samples.len()); + let reopened = load_version_control(&store, region.region_id, &purger).await; + assert_eq!(2, reopened.current().samples.len()); + assert!(snapshot.disk_usage() >= 16); + let stats = reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + .await + .unwrap(); + assert_eq!(0, stats.built_samples); + + // Rebuilding a missing sidecar replaces its generation but retains old readers' files. + let old = snapshot.samples.values().next().unwrap().clone(); + store + .delete(&old.entry().artifact.objects[0]) + .await + .unwrap(); + let stats = reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + .await + .unwrap(); + assert_eq!(1, stats.built_samples); + assert!(purges.try_recv().is_err()); + drop(snapshot); + assert!(purges.try_recv().is_err()); + drop(old); + let request = purges.try_recv().unwrap(); + assert_eq!(2, request.objects.unwrap().len()); + engine.stop().await.unwrap(); +} + +#[tokio::test] +async fn format_skeleton_abort_removes_all_objects() { + let mut env = TestEnv::with_prefix("format-abort").await; + let (engine, region) = crate::series_index::tests::prepare_region(&mut env).await; + let store = ObjectStore::new(object_store::services::Memory::default()).unwrap(); + let (purger, _receiver) = series_index_channel(store.clone()); + let version = region.version(); + let file = version + .ssts + .levels() + .iter() + .flat_map(|l| l.files()) + .next() + .unwrap() + .clone(); + let fake = FakeSamples::default(); + fake.fail.store(true, Ordering::Relaxed); + assert!( + crate::series_index::builder::build_samples( + &store, ®ion, &version, file, &fake, &purger + ) + .await + .is_err() + ); + assert!( + store + .list_with(&format!("{}/samples/", region.region_id.as_u64())) + .recursive(true) + .await + .unwrap() + .iter() + .all(|entry| entry.metadata().is_dir()) + ); + assert!(region.series_index_version().samples.is_empty()); + engine.stop().await.unwrap(); +} + +#[derive(Debug, Default)] +struct FakeIndex; + +#[async_trait] +impl SeriesIndexFormat for FakeIndex { + fn format(&self) -> ArtifactFormat { + ArtifactFormat::FakeIndex + } + + async fn writer( + &self, + metadata: RegionMetadataRef, + store: ObjectStore, + entry: &SeriesIndexEntry, + ) -> Result> { + let path = format!( + "{}/series/{}.fake", + metadata.region_id.as_u64(), + entry.index_uuid + ); + store.write(&path, "fake").await.context(OpenDalSnafu)?; + Ok(Box::new(FakeWriter { + store, + artifact: ArtifactMetadata { + format: self.format(), + generation: entry.index_uuid, + objects: vec![path], + file_size: 4, + }, + fail: Arc::new(AtomicBool::new(false)), + })) + } +} + +#[tokio::test] +async fn format_index_rebuild_is_scoped_and_scans_fall_back_to_ssts() { + let mut env = TestEnv::with_prefix("format-index").await; + // Index builds require four SSTs per bucket. + let timestamps = [1000, 2000, 3000, 4000, 101000, 102000, 103000, 104000]; + let (engine, region) = prepare_region_with_config( + &mut env, + ×tamps, + MitoConfig { + experimental_enable_series_index: true, + experimental_series_scan_v2: true, + experimental_series_index_maintenance_interval: Duration::from_secs(3600), + min_compaction_interval: Duration::from_secs(3600), + ..Default::default() + }, + ) + .await; + *region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats { + index: Some(Arc::new(FakeIndex)), + ..Default::default() + }); + let store = region.series_index_store.clone().unwrap(); + let (purger, mut purges) = series_index_channel(store.clone()); + let reconcile = || { + reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + }; + assert_eq!(2, reconcile().await.unwrap().built_series); + let before = region.series_index_version(); + assert!( + before + .series_indexes + .values() + .all(|h| h.entry().format() == ArtifactFormat::FakeIndex) + ); + assert_eq!(0, reconcile().await.unwrap().built_series); + let scanner = engine + .scanner( + region.region_id, + store_api::storage::ScanRequest { + distribution: Some(store_api::storage::TimeSeriesDistribution::PerSeries), + ..Default::default() + }, + ) + .await + .unwrap(); + let batches = scanner + .scan() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + timestamps.len(), + batches.iter().map(|batch| batch.num_rows()).sum::() + ); + drop(scanner); + + let missing = before.series_indexes.values().next().unwrap(); + store + .delete(&missing.entry().artifact.as_ref().unwrap().objects[0]) + .await + .unwrap(); + assert_eq!(1, reconcile().await.unwrap().built_series); + let after = region.series_index_version(); + assert_eq!( + 1, + after + .series_indexes + .keys() + .filter(|id| before.series_indexes.contains_key(id)) + .count() + ); + assert!(purges.try_recv().is_err()); + drop(before); + assert!(purges.try_recv().is_ok()); + + // Disabling the injected writer restores ordinary Parquet artifacts without a read adapter. + *region.series_index_version_control.formats.write().unwrap() = + Arc::new(SeriesFormats::default()); + assert_eq!(2, reconcile().await.unwrap().built_series); + assert!( + region + .series_index_version() + .series_indexes + .values() + .all(|h| h.entry().artifact.is_none()) + ); + assert_eq!(0, reconcile().await.unwrap().built_series); + engine.stop().await.unwrap(); +} + +struct FailedCatalogWriter(oio::Writer); + +impl mock::Write for FailedCatalogWriter { + async fn write(&mut self, buffer: mock::Buffer) -> mock::Result<()> { + self.0.write(buffer).await + } + async fn close(&mut self) -> mock::Result { + Err(mock::Error::new( + mock::ErrorKind::Unexpected, + "injected artifact catalog failure", + )) + } + async fn abort(&mut self) -> mock::Result<()> { + self.0.abort().await + } +} + +#[tokio::test] +async fn format_catalog_failure_preserves_published_artifacts() { + let mut env = TestEnv::with_prefix("format-catalog-failure").await; + let (engine, region) = crate::series_index::tests::prepare_region(&mut env).await; + *region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats { + index: Some(Arc::new(FakeIndex)), + samples: Some(Arc::new(FakeSamples::default())), + }); + let store = ObjectStore::new(object_store::services::Memory::default()).unwrap(); + let (purger, mut purges) = series_index_channel(store.clone()); + reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + .await + .unwrap(); + let before = region.series_index_version(); + let catalog_path = crate::series_index::catalog::samples_catalog_path(region.region_id); + let catalog = store.read(&catalog_path).await.unwrap().to_bytes(); + let old = before.samples.values().next().unwrap(); + store + .delete(&old.entry().artifact.objects[0]) + .await + .unwrap(); + let layer = MockLayerBuilder::default() + .writer_factory(Arc::new(|path, _, inner| { + if path.ends_with("samples.json") { + Box::new(FailedCatalogWriter(inner)) + } else { + inner + } + })) + .build() + .unwrap(); + let error = reconcile_series_indexes( + 0, + store.clone().layer(layer), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + .await + .unwrap_err(); + assert!(format!("{error:?}").contains("injected artifact catalog failure")); + assert!(Arc::ptr_eq(&before, ®ion.series_index_version())); + assert_eq!(catalog, store.read(&catalog_path).await.unwrap().to_bytes()); + let cleanup = purges.try_recv().unwrap().objects.unwrap(); + assert_eq!(2, cleanup.len()); + assert!( + cleanup + .iter() + .all(|path| !old.entry().artifact.objects.contains(path)) + ); + assert!(purges.try_recv().is_err()); + let reopened = load_version_control(&store, region.region_id, &purger).await; + assert_eq!( + old.entry(), + reopened.current().samples[&old.entry().file_id].entry() + ); + engine.stop().await.unwrap(); +} diff --git a/src/mito2/src/series_index/maintenance.rs b/src/mito2/src/series_index/maintenance.rs index f5221bb1ebe..232e17ac266 100644 --- a/src/mito2/src/series_index/maintenance.rs +++ b/src/mito2/src/series_index/maintenance.rs @@ -20,6 +20,7 @@ use std::time::{Duration, Instant}; use common_telemetry::{debug, info}; use object_store::ObjectStore; +use snafu::ResultExt; use store_api::storage::{FileId, RegionId}; use crate::error::Result; @@ -40,16 +41,23 @@ use crate::series_index::version::{SeriesIndexFileHandle, SeriesIndexVersion}; /// Retires newly completed series files unless their snapshot is published. #[derive(Default)] -struct UnpublishedSeriesFiles(Vec); +struct UnpublishedSeriesFiles( + Vec, + Vec, +); impl UnpublishedSeriesFiles { fn disarm(&mut self) { self.0.clear(); + self.1.clear(); } } impl Drop for UnpublishedSeriesFiles { fn drop(&mut self) { + for handle in &self.1 { + handle.mark_deleted(); + } for handle in &self.0 { handle.mark_deleted(); } @@ -58,6 +66,8 @@ impl Drop for UnpublishedSeriesFiles { #[derive(Debug, Default)] pub(crate) struct ReconcileStats { + pub(crate) built_samples: usize, + pub(crate) removed_samples: usize, pub(crate) source_files: usize, pub(crate) built_range: usize, pub(crate) built_series: usize, @@ -69,7 +79,13 @@ pub(crate) struct ReconcileStats { impl ReconcileStats { fn changed(&self) -> bool { - self.built_range + self.built_series + self.removed_range + self.removed_series > 0 + self.built_samples + + self.removed_samples + + self.built_range + + self.built_series + + self.removed_range + + self.removed_series + > 0 } } @@ -201,12 +217,43 @@ async fn build_index_version( } (false, _) => Vec::new(), }; - let plan = plan_series_indexes( - buckets, - current.index_buckets.clone(), - version.options.ttl, - now_ms, + let formats = region + .series_index_version_control + .formats + .read() + .unwrap() + .clone(); + let selected_format = formats.index.as_ref().map_or( + crate::series_index::artifact::ArtifactFormat::Parquet, + |backend| backend.format(), ); + let mut unusable = HashSet::new(); + for handle in current.series_indexes.values() { + let entry = handle.entry(); + let mut reusable = entry.format() == selected_format; + // Legacy Parquet indexes retain their existing maintenance behavior. + if reusable && let Some(artifact) = &entry.artifact { + for object in &artifact.objects { + reusable &= store + .exists(object) + .await + .context(crate::error::OpenDalSnafu)?; + } + } + if !reusable { + unusable.insert(entry.index_uuid); + } + } + let mut index_buckets = current.index_buckets.clone(); + for bucket in index_buckets.values_mut() { + if bucket.index_ids.iter().any(|id| unusable.contains(id)) { + // Force replacement only for the affected bucket. Retain its geometry and + // handles until a replacement is built and its catalog is published. + bucket.compaction_window_secs = 0; + bucket.window_sequences.clear(); + } + } + let plan = plan_series_indexes(buckets, index_buckets, version.options.ttl, now_ms); stats.computed_buckets = plan.computed_buckets; stats.skipped_buckets = plan.skipped_buckets; let mut next = prune_index_version(¤t, &visible, &plan.expired_index_ids, &mut stats); @@ -218,6 +265,7 @@ async fn build_index_version( } if plan.builds.is_empty() && next.is_none() + && formats.samples.is_none() && (!enable_range_index || current.range_indexes.len() == visible.len()) { return Ok((None, stats)); @@ -225,13 +273,50 @@ async fn build_index_version( let SeriesIndexVersion { mut range_indexes, mut series_indexes, + mut samples, .. } = next.unwrap_or_else(|| SeriesIndexVersion { + samples: current.samples.clone(), range_indexes: current.range_indexes.clone(), series_indexes: current.series_indexes.clone(), index_buckets: Default::default(), }); let index_buckets = plan.index_buckets; + if let Some(backend) = &formats.samples { + for file in &files { + let id = file.file_id().file_id(); + let reusable = if let Some(handle) = samples.get(&id) { + let artifact = &handle.entry().artifact; + let mut exists = artifact.format == backend.format(); + for path in &artifact.objects { + exists &= store + .exists(path) + .await + .context(crate::error::OpenDalSnafu)?; + } + exists + } else { + false + }; + if reusable { + continue; + } + if let Some(handle) = crate::series_index::builder::build_samples( + store, + region, + version, + file.clone(), + backend.as_ref(), + purger, + ) + .await? + { + unpublished.1.push(handle.clone()); + samples.insert(id, handle); + stats.built_samples += 1; + } + } + } for id in &plan.superseded_index_ids { series_indexes.remove(id); } @@ -246,8 +331,16 @@ async fn build_index_version( range_indexes.insert(entry.file_id, entry); } } - let series_handle = - build_series_index(store, region, version, &bucket, &expected, purger).await?; + let series_handle = build_series_index( + store, + region, + version, + &bucket, + &expected, + formats.index.as_deref(), + purger, + ) + .await?; unpublished.0.push(series_handle.clone()); stats.built_series += 1; series_indexes.insert(expected.index_uuid, series_handle); @@ -276,6 +369,7 @@ async fn build_index_version( return Ok((None, stats)); } let next = SeriesIndexVersion { + samples, range_indexes, series_indexes, index_buckets, @@ -293,7 +387,8 @@ fn prune_index_version( expired_index_ids: &[FileId], stats: &mut ReconcileStats, ) -> Option { - if current.range_indexes.keys().all(|id| visible.contains(id)) + if current.samples.keys().all(|id| visible.contains(id)) + && current.range_indexes.keys().all(|id| visible.contains(id)) && expired_index_ids .iter() .all(|id| !current.series_indexes.contains_key(id)) @@ -301,10 +396,13 @@ fn prune_index_version( return None; } let mut next = SeriesIndexVersion { + samples: current.samples.clone(), range_indexes: current.range_indexes.clone(), series_indexes: current.series_indexes.clone(), index_buckets: current.index_buckets.clone(), }; + next.samples.retain(|id, _| visible.contains(id)); + stats.removed_samples = current.samples.len() - next.samples.len(); next.range_indexes .retain(|file_id, _| visible.contains(file_id)); for id in expired_index_ids { @@ -322,6 +420,31 @@ async fn persist_index_catalogs( next: &SeriesIndexVersion, stats: &ReconcileStats, ) -> Result<()> { + let samples_path = crate::series_index::catalog::samples_catalog_path(region_id); + if !next.samples.is_empty() + || stats.removed_samples > 0 + || store + .exists(&samples_path) + .await + .context(crate::error::OpenDalSnafu)? + { + // A single object write commits all references. A failed write leaves the prior + // catalog usable while the unpublished-output guard retires the new artifacts. + return store_catalog( + store, + &samples_path, + &crate::series_index::catalog::SamplesCatalog { + entries: next.samples.values().map(|h| h.entry().clone()).collect(), + series: next + .series_indexes + .values() + .map(|h| h.entry().clone()) + .collect(), + ranges: next.range_indexes.values().copied().collect(), + }, + ) + .await; + } if stats.built_range + stats.removed_range > 0 { let mut range_entries = next.range_indexes.values().copied().collect::>(); range_entries @@ -364,6 +487,13 @@ async fn persist_index_catalogs( /// Publishes the snapshot and retires series files absent from the new version. fn publish_index_version(region: &MitoRegionRef, next: Arc) { let previous = region.series_index_version_control.publish(next.clone()); + for (id, handle) in &previous.samples { + if next.samples.get(id).is_none_or(|next| { + next.entry().artifact.generation != handle.entry().artifact.generation + }) { + handle.mark_deleted(); + } + } for (id, handle) in &previous.series_indexes { if !next.series_indexes.contains_key(id) { // Purge only after readers release their retained handles. @@ -405,6 +535,7 @@ mod tests { SeriesIndexFileHandle::new( RegionId::new(1, 1), SeriesIndexEntry { + artifact: None, index_uuid: series_id, file_size: 20, bucket_start: Timestamp::new_second(0), diff --git a/src/mito2/src/series_index/purger.rs b/src/mito2/src/series_index/purger.rs index a3eb6d89e38..66eda84c327 100644 --- a/src/mito2/src/series_index/purger.rs +++ b/src/mito2/src/series_index/purger.rs @@ -39,9 +39,10 @@ impl IndexFileType { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct PurgeRequest { pub(crate) file_id: RegionFileId, + pub(crate) objects: Option>, } #[derive(Clone)] @@ -57,6 +58,13 @@ impl Debug for IndexFilePurger { } impl IndexFilePurger { + pub(crate) fn purge_objects(&self, file_id: RegionFileId, objects: Vec) { + self.purge(PurgeRequest { + file_id, + objects: Some(objects), + }); + } + pub(crate) fn purge(&self, request: PurgeRequest) { if let Err(error) = self.sender.send(request) { let store = self.store.clone(); @@ -87,17 +95,24 @@ pub(crate) async fn run_index_purge_task( } async fn purge_file(store: &ObjectStore, request: PurgeRequest) { - let path = series_index_path(request.file_id.region_id(), request.file_id.file_id()); - match store.delete(&path).await { - Ok(()) => { - file_operation(IndexFileType::Series, "delete", "success"); - } - Err(error) if error.kind() == ErrorKind::NotFound => { - file_operation(IndexFileType::Series, "delete", "success"); - } - Err(error) => { - file_operation(IndexFileType::Series, "delete", "failure"); - warn!(error; "Failed to delete series index, index_type: {}, path: {}, phase: deletion", IndexFileType::Series.as_str(), path); + let objects = request.objects.unwrap_or_else(|| { + vec![series_index_path( + request.file_id.region_id(), + request.file_id.file_id(), + )] + }); + for path in objects { + match store.delete(&path).await { + Ok(()) => { + file_operation(IndexFileType::Series, "delete", "success"); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + file_operation(IndexFileType::Series, "delete", "success"); + } + Err(error) => { + file_operation(IndexFileType::Series, "delete", "failure"); + warn!(error; "Failed to delete series index, index_type: {}, path: {}, phase: deletion", IndexFileType::Series.as_str(), path); + } } } } @@ -129,7 +144,10 @@ mod tests { let path = series_index_path(file_id.region_id(), file_id.file_id()); store.write(&path, "index").await.unwrap(); paths.push(path); - purger.purge(PurgeRequest { file_id }); + purger.purge(PurgeRequest { + file_id, + objects: None, + }); } drop(purger); tokio::time::timeout( @@ -151,7 +169,10 @@ mod tests { let file_id = RegionFileId::new(RegionId::new(1, 1), FileId::random()); let path = series_index_path(file_id.region_id(), file_id.file_id()); store.write(&path, "index").await.unwrap(); - purger.purge(PurgeRequest { file_id }); + purger.purge(PurgeRequest { + file_id, + objects: None, + }); tokio::time::timeout(Duration::from_secs(10), async { while store.exists(&path).await.unwrap() { tokio::task::yield_now().await; diff --git a/src/mito2/src/series_index/searcher.rs b/src/mito2/src/series_index/searcher.rs index 0b9befe4b8a..a2589b3bda9 100644 --- a/src/mito2/src/series_index/searcher.rs +++ b/src/mito2/src/series_index/searcher.rs @@ -312,6 +312,7 @@ mod tests { ) -> (SeriesIndexFileHandle, UnboundedReceiver) { let (purger, receiver) = series_index_channel(store.clone()); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: common_time::Timestamp::new_second(0), diff --git a/src/mito2/src/series_index/tests.rs b/src/mito2/src/series_index/tests.rs index 631bdc75b87..145c2088cce 100644 --- a/src/mito2/src/series_index/tests.rs +++ b/src/mito2/src/series_index/tests.rs @@ -63,7 +63,7 @@ pub(crate) async fn prepare_region_with_timestamps( .await } -async fn prepare_region_with_config( +pub(crate) async fn prepare_region_with_config( env: &mut TestEnv, timestamps: &[i64], config: MitoConfig, @@ -726,7 +726,7 @@ async fn test_failed_reconcile_retires_only_unpublished_series() { .unwrap(); ranges.insert(entry.file_id, entry); } - let handle = build_series_index(store, ®ion, &version, bucket, entry, purger) + let handle = build_series_index(store, ®ion, &version, bucket, entry, None, purger) .await .unwrap(); let reused_path = series_index_path(region.region_id, entry.index_uuid); @@ -891,6 +891,7 @@ async fn test_drop_catalogs_and_retained_snapshot_after_task_stop() { let store = ObjectStore::new(Memory::default()).unwrap(); let region_id = RegionId::new(1, 1); let entry = SeriesIndexEntry { + artifact: None, file_size: 0, index_uuid: FileId::random(), bucket_start: Timestamp::new_second(0), diff --git a/src/mito2/src/series_index/version.rs b/src/mito2/src/series_index/version.rs index fa2115fc1f2..7d819a0df55 100644 --- a/src/mito2/src/series_index/version.rs +++ b/src/mito2/src/series_index/version.rs @@ -82,9 +82,15 @@ struct SeriesIndexFileHandleInner { impl Drop for SeriesIndexFileHandleInner { fn drop(&mut self) { if self.deleted.load(Ordering::Acquire) { - self.purger.purge(PurgeRequest { - file_id: self.file_id, - }); + if let Some(artifact) = &self.entry.artifact { + self.purger + .purge_objects(self.file_id, artifact.objects.clone()); + } else { + self.purger.purge(PurgeRequest { + file_id: self.file_id, + objects: None, + }); + } } } } @@ -94,6 +100,7 @@ impl Drop for SeriesIndexFileHandleInner { pub(crate) struct SeriesIndexVersion { /// Range indexes for visible SSTs; reconciliation removes IDs absent from its SST snapshot. /// Physical deletion is independently handled by the SST file purger. + pub(crate) samples: HashMap, pub(crate) range_indexes: HashMap, pub(crate) series_indexes: HashMap, pub(crate) index_buckets: BTreeMap, @@ -110,6 +117,7 @@ impl SeriesIndexVersion { IndexBucket::from_entry(handle.entry()).insert_into(&mut index_buckets); } Self { + samples: Default::default(), range_indexes, series_indexes, index_buckets, @@ -118,10 +126,15 @@ impl SeriesIndexVersion { /// Approximate installed usage; old snapshots and unpublished outputs are excluded. pub(crate) fn disk_usage(&self) -> u64 { - self.range_indexes + self.samples .values() - .map(|entry| entry.file_size) + .map(|handle| handle.entry().artifact.file_size) .sum::() + + self + .range_indexes + .values() + .map(|entry| entry.file_size) + .sum::() + self .series_indexes .values() @@ -130,6 +143,9 @@ impl SeriesIndexVersion { } fn mark_all_deleted(&self) { + self.samples + .values() + .for_each(|handle| handle.mark_deleted()); self.series_indexes .values() .for_each(SeriesIndexFileHandle::mark_deleted); @@ -140,6 +156,7 @@ impl SeriesIndexVersion { #[derive(Debug, Default)] pub(crate) struct SeriesIndexVersionControl { current: RwLock>, + pub(crate) formats: RwLock>, } impl SeriesIndexVersionControl { diff --git a/src/promql/AGENTS.md b/src/promql/AGENTS.md new file mode 100644 index 00000000000..dc90c5a0eec --- /dev/null +++ b/src/promql/AGENTS.md @@ -0,0 +1,110 @@ +# promql — Agent & Contributor Guide + +Navigation map for `src/promql`. Repo-wide invariants: +[`.agents/architecture-invariants.md`](../../.agents/architecture-invariants.md). + +`promql` owns PromQL evaluation: the series/evaluated data model, range and +instant evaluation, function kernels, vector matching, and the PromQL extension +operators. Statement-to-plan translation lives in `src/query/src/promql/`. + +## Module map + +| Area | Path | Entry point | +| --- | --- | --- | +| Data model | `src/promql/src/grid_series.rs`, `src/promql/src/evaluated.rs` | Grid series, evaluated chunks | +| Evaluation | `src/promql/src/range_eval.rs`, `src/promql/src/instant_eval.rs` | Range and instant evaluation | +| Kernels | `src/promql/src/functions.rs`, `src/promql/src/functions/` | PromQL functions | +| Matching | `src/promql/src/vector_match.rs` | Binary vector matching | +| Extension operators | `src/promql/src/extension_plan.rs`, `src/promql/src/extension_plan/` | Logical nodes and execution plans | +| Native model | `src/promql/src/native/` | Compiled tree, chunks, display, metrics | + +## Two evaluation paths, and their status + +The easiest thing to get wrong here. Both exist; only one runs. + +- **Extension operators are live.** `PromPlanner` builds `SeriesDivide`, + `SeriesNormalize`, `RangeManipulate`, `InstantManipulate`, `EmptyMetric` and + `UnionDistinctOn`; `PromExtensionPlanner` + (`src/promql/src/extension_plan/planner.rs`) maps each logical node to its + execution plan, one branch per node, `None` for anything else. +- **Nothing plans into the native model yet.** `src/query/src/promql/native_compile.rs` + compiles a statement into a `NativeTree`, but no planner calls it, so a tree + built here reaches no execution. `src/promql/src/native/` holds the tree, the + chunk protocol, display and metrics, and — once a runtime lands — the driver + that runs a tree node by node. Compiling and running are separate concerns + owned by separate files; do not assume a tree is executable because it + compiles. + +## Change coupling + +- **An extension node reaches a datanode only if three things line up**: a payload + branch in `src/common/substrait/src/extension_serializer.rs`, a physical branch + in `PromExtensionPlanner`, and a `Commutativity::Commutative` arm in + `check_extension_plan` (`src/query/src/dist_plan/commutativity.rs`). Missing the + third is silent: the node falls through to `Unsupported`, the distributed + boundary is placed above it, and it never reaches a region — with no error. +- A new logical node also needs its physical planner reachable from + `DfQueryPlanner::create_physical_plan`, assembled in + `src/query/src/query_engine/state.rs`. +- User-visible changes need planner coverage plus a sqlness case under + `tests/cases/standalone/common/promql/`. + +## Gotchas + +- **One operation, two implementations; build on the live one.** `InstantManipulate` + routes to `InstantManipulateExec`. The native alternative + `NativeInstantManipulateExec` is crate-private and + `InstantManipulate::try_to_native_execution_plan` has no caller on any branch — + its doc is written for a caller that does not exist. +- **A native window requires non-interleaved input.** Only `Contiguous` and + `KeyThenTime` are accepted; `Interleaved` is refused when compiled. +- **A series closes exactly once**, on the chunk that says so — per input series, + not per label set. A node that rewrites labels can hand two input series over + under one label set, and each closes on its own chunk, so a label set may be + closed more than once; a merge above them must not read the second close as a + defect. That closing chunk is the only way a consumer distinguishes "nothing more + here" from "no more input": dropping it leaves a series open, and a child that + merges regions must recompute finality globally rather than pass a region-local + flag through. +- **Raw-frame labels come from the binding, not the schema.** A raw series frame + carries an opaque encoded primary key while its schema metadata describes only + the format: `greptime.series.layout`, `.content`, `.labels` and `.time`, plus + the producer ordering the adapter adds. Nothing in that metadata binds the key + to a decode — the frame decoder compares those format keys and the field list, + and never a label identity. `unpack_raw_series` therefore requires a + caller-supplied `RawLabelSource`: decode with the source that was actually bound + when the stream was produced, because the frame schema does not replace that + binding. The metadata describes format, not label identity. +- **A wrong label binding is silently wrong, not an error.** Storage validates the + binding it is given (metadata identity, tag-ness, name matches, no duplicates) + but cannot tell whether that set is the one the query meant, and the metric name + is not part of that check. + +## Testing + +```bash +cargo nextest run -p promql +cargo nextest run -p query # planner and native-compile changes +cargo sqlness bare -t # PromQL cases live under tests/cases/standalone/common/promql/ +``` + +## Not wired up yet + +The storage side of the raw path exists; what is missing is planner wiring. Both +gaps below are structural, not bugs. + +- **The native tree has no planner entry point.** `native_compile::compile` has no + caller, and a `NativeTree` becomes a physical plan only once something builds it + while it still holds the AST and the selector binding — the point where the + matchers, `__field__` and the lookback are still available. Compiling later, from + a plan, loses them. +- **No planner reaches a raw scan.** mito2 implements + `RegionScanner::raw_series_schema` (`mito2/src/read/series_scan.rs`), so + `RegionScanExec::try_to_series_scan` can build `SeriesScanExec` from a prepared + scanner; the trait's `Ok(None)` default only refuses engines that have not + implemented it. What is missing is the planner side: nothing selects that + conversion, and `SeriesScanExec::children()` is empty, so wrapping a + `RegionScanExec` before partition preparation leaves `ParallelizeScan` unable to + assign partitions. Either give the partitioner a child it can see, or convert to + the native leaf only after partition preparation — the second is the contract + `RegionScanExec::try_to_series_scan` documents. diff --git a/src/promql/src/extension_plan/instant_manipulate.rs b/src/promql/src/extension_plan/instant_manipulate.rs index 85b08f5ca41..255d51be2c4 100644 --- a/src/promql/src/extension_plan/instant_manipulate.rs +++ b/src/promql/src/extension_plan/instant_manipulate.rs @@ -20,7 +20,10 @@ use std::sync::Arc; use std::task::{Context, Poll}; use common_query::prelude::{greptime_native_histogram, greptime_value}; -use datafusion::arrow::array::{Array, Float64Array, TimestampMillisecondArray, UInt64Array}; +use datafusion::arrow::array::{ + Array, ArrayRef, Float64Array, LargeListArray, LargeStringArray, ListArray, StringArray, + StructArray, TimestampMillisecondArray, UInt64Array, +}; use datafusion::arrow::datatypes::{DataType, SchemaRef}; use datafusion::arrow::record_batch::RecordBatch; use datafusion::common::stats::Precision; @@ -28,6 +31,7 @@ use datafusion::common::tree_node::TreeNodeRecursion; use datafusion::common::{DFSchema, DFSchemaRef, ScalarValue}; use datafusion::error::{DataFusionError, Result as DataFusionResult}; use datafusion::execution::context::TaskContext; +use datafusion::execution::memory_pool::{MemoryConsumer, MemoryReservation}; use datafusion::logical_expr::{ EmptyRelation, Expr, Extension, LogicalPlan, UserDefinedLogicalNodeCore, }; @@ -58,7 +62,7 @@ use crate::extension_plan::{ nanoseconds_per_native_tick, prometheus_stale_sample_column, resolve_column_name, serialize_column_index, timestamp_unit, }; -use crate::instant_eval::{MAX_INSTANT_MANIPULATE_OUTPUT_POINTS, select_instants}; +use crate::instant_eval::select_instants; use crate::metrics::PROMQL_SERIES_COUNT; fn mixed_sample_fields(field: Option<&str>) -> [Option<&str>; 2] { @@ -75,6 +79,15 @@ fn mixed_sample_fields(field: Option<&str>) -> [Option<&str>; 2] { /// This plan will try to align the input time series, for every timestamp between /// `start` and `end` with step `interval`. Find in the `lookback` range if data /// is missing at the given timestamp. +/// +/// The memory pool bounds one instant query, not a point count: the execution reserves the +/// workspace its selection allocates before allocating it, and a pool that cannot meet the +/// reservation refuses the query with `ResourcesExhausted` (see +/// [`InstantManipulateStream::manipulate_reserved`]). That bound exists only where the pool does: +/// a resolved `query.memory_pool_size` of zero leaves DataFusion's unbounded pool attached, whose +/// growth always succeeds, so an operator that leaves the option unset has no bound on this path +/// at all. What one query can cost still grows with the number of series, which no reservation of +/// a single series accounts for. #[derive(Debug, PartialEq, Eq, Hash)] pub struct InstantManipulate { start: Millisecond, @@ -530,6 +543,10 @@ impl ExecutionPlan for InstantManipulateExec { count: num_series.clone(), }); + // The reservation is the operator's own, registered before the child is polled so that a + // pool which cannot hold one series' workspace refuses it rather than the child's memory. + let reservation = + MemoryConsumer::new("InstantManipulateStream").register(context.memory_pool()); let input = self.input.execute(partition, context)?; let schema = input.schema(); let time_index = schema @@ -559,6 +576,7 @@ impl ExecutionPlan for InstantManipulateExec { input, metric: baseline_metric, num_series, + reservation, })) } @@ -638,6 +656,8 @@ pub struct InstantManipulateStream { metric: BaselineMetrics, /// Number of series processed. num_series: Count, + /// What the workspace of the selection being built holds, reserved before it is allocated. + reservation: MemoryReservation, } impl RecordBatchStream for InstantManipulateStream { @@ -656,7 +676,7 @@ impl Stream for InstantManipulateStream { if batch.num_rows() != 0 { self.num_series.add(1); } - let result = Ok(batch).and_then(|batch| self.manipulate(batch)); + let result = self.manipulate_reserved(batch); self.metric.elapsed_compute().add_elapsed(timer); Poll::Ready(Some(result)) } @@ -671,6 +691,24 @@ impl Stream for InstantManipulateStream { } impl InstantManipulateStream { + /// Manipulates one complete series with the workspace the selection allocates reserved first. + /// + /// One grid point of the selection is decided per selected sample position, so the workspace is + /// proportional to the points of the clipped grid rather than to the samples of the series: the + /// two arrays [`select_instants`] returns, the take indices the batch is built with, and one + /// value per point for every column of the batch. That is what the point-count limit this + /// operator used to carry stood for, and [`Self::workspace_bytes`] derives it term by term. + /// + /// The reservation goes back as soon as the batch is built, because what it covers is this + /// operator's workspace and not its output: the batch belongs to whoever reads it from here and + /// is charged to the pool there, as the output of any operator is. + fn manipulate_reserved(&mut self, input: RecordBatch) -> DataFusionResult { + self.reservation.try_grow(self.workspace_bytes(&input))?; + let result = self.manipulate(input); + self.reservation.free(); + result + } + /// Manipulates one complete series sorted by timestamp. The planner enforces /// this input contract with a sort followed by [`SeriesDivide`]. /// @@ -722,7 +760,6 @@ impl InstantManipulateStream { return self.take_record_batch_optional(input, indices, evaluation_timestamps); } } - let scale = nanoseconds_per_native_tick(self.time_unit); let stale_sample_columns = self.field_indices.map(|index| { index.and_then(|index| prometheus_stale_sample_column(input.column(index).as_ref())) }); @@ -738,48 +775,8 @@ impl InstantManipulateStream { let timestamp_null_count = timestamps.null_count(); let timestamps = timestamps.values(); let len = timestamps.len(); - // Shift the native-tick timeline in i128 before comparing samples. Doing - // this in the Arrow storage unit can overflow even when the shifted - // PromQL millisecond evaluation time is representable. - let to_nanoseconds = - |timestamp: i64| (timestamp as i128) * scale + (self.offset as i128) * 1_000_000; - let first_ns = to_nanoseconds(timestamps[0]); - let last_ns = to_nanoseconds(timestamps[len - 1]); - // An exact sample remains useful with zero lookback. Otherwise the lower - // boundary is exclusive, so subtract one nanosecond from its final window. - let last_useful = if self.lookback_delta == 0 { - last_ns - } else { - last_ns + (self.lookback_delta as i128) * 1_000_000 - 1 - }; - let first_ms = (first_ns + 999_999).div_euclid(1_000_000); - let last_ms = last_useful.div_euclid(1_000_000); - let query_start = self.start as i128; - let query_end = self.end as i128; - let interval = self.interval as i128; - let max_start = first_ms.max(query_start); - let min_end = last_ms.min(query_end); - let (aligned_start, aligned_end) = if max_start > min_end { - (1, 0) - } else { - ( - query_start + (max_start - query_start) / interval * interval, - query_end - (query_end - min_end) / interval * interval, - ) - }; - let estimated_points = if aligned_end >= aligned_start { - (aligned_end - aligned_start) / interval + 1 - } else { - 0 - }; - if estimated_points > MAX_INSTANT_MANIPULATE_OUTPUT_POINTS as i128 { - return Err(DataFusionError::Execution(format!( - "InstantManipulate output points exceed limit: {estimated_points} > {MAX_INSTANT_MANIPULATE_OUTPUT_POINTS}" - ))); - } - let estimated_points = estimated_points as usize; - let aligned_start = aligned_start as i64; - let aligned_end = aligned_end as i64; + let (aligned_start, aligned_end) = self.clipped_range(timestamps); + let estimated_points = self.range_points(aligned_start, aligned_end); // Narrow `i64` nanoseconds whenever the batch fits them, exact `i128` otherwise. let narrow = (timestamp_null_count == 0) .then(|| { @@ -794,8 +791,19 @@ impl InstantManipulateStream { }) .flatten(); if let Some(scan) = &narrow { - debug_assert_eq!(i64::try_from(first_ns), Ok(scan.shift(timestamps[0]))); - debug_assert_eq!(i64::try_from(last_ns), Ok(scan.shift(timestamps[len - 1]))); + // The clip converted the series bounds to milliseconds; this asserts the scan the + // selection walks agrees with them on those two samples. + let scale = nanoseconds_per_native_tick(self.time_unit); + let to_nanoseconds = + |timestamp: i64| (timestamp as i128) * scale + (self.offset as i128) * 1_000_000; + debug_assert_eq!( + i64::try_from(to_nanoseconds(timestamps[0])), + Ok(scan.shift(timestamps[0])) + ); + debug_assert_eq!( + i64::try_from(to_nanoseconds(timestamps[len - 1])), + Ok(scan.shift(timestamps[len - 1])) + ); } let (take_indices, aligned_ts) = match narrow { Some(scan) => self.scan_instants::( @@ -895,6 +903,215 @@ impl InstantManipulateStream { .map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?; Ok(result) } + + /// What one selected series' workspace holds, in bytes, before it is allocated. + /// + /// The figure follows the reasoning of `InstantRuntime::gather_bytes` in `native::execute` and + /// of `NativeInstantStream::finish_series` in `native_exec`: a selection decides one point of + /// its grid per sample position it reads, so what it allocates grows with the points of the + /// clipped grid and not with the samples of the series. Each term below is one allocation, and + /// the ones this path shares with those are named as they are there. + /// + /// Per selected point, [`Self::clipped_points`] points being the most it can decide: + /// + /// - A `u64` source row index and an `i64` evaluation timestamp: the two vectors + /// [`select_instants`] returns, and the scan of [`Self::scan_instants`] returns the same pair. + /// This is that node's "two arrays [`select_instants`] returns" term. + /// - The same indices again, for the `UInt64Array` [`Self::take_record_batch_optional`] takes + /// the column values with: `take_indices.clone()` is a second copy of them. + /// - The timestamps again, as an allowance for the output's time column: they are moved into it + /// when they fit and copied when they do not, and a move costs nothing to bound. + /// + /// Per column, one row per point: `take` allocates a row of the column for every point, so a + /// column costs its widest row per point -- the same term as that node's "one `f64` per field" + /// -- and one byte per point for the validity buffer it allocates, charged a byte for a bit so + /// that the figure stays an upper bound. A reused TSID column is sliced instead of taken + /// whenever the output is no longer than it and materialized a row per point otherwise, so the + /// same row bounds both. [`row_bytes`] gives the width, and a column of a type the series + /// frames of this path do not carry is charged for the bytes it already occupies once rather + /// than as a row per point. + /// + /// Per native histogram column, the widest row is not one value: a histogram row is a struct of + /// scalar columns whose values are copied and bucket lists whose values are copied for the row + /// the point selects, so a point costs the scalars, the list offsets and the widest payload the + /// column holds. That node charges its histogram payload the same way, "at the largest one the + /// field holds and per *point* rather than per row", because one source row can be selected by + /// every point of the grid and still be copied for each of them. + /// + /// Not charged, deliberately: the headers this selection builds per batch rather than per point + /// (the sample iterators and `Samples` clones of the fast path are constant per field), the + /// `Arc` bumps of a shared column, and the input batch, which belongs to the child. No term + /// doubles: every vector here is created with its exact capacity and `take` sizes its output by + /// the point count, unlike the builders that node charges twice. + fn workspace_bytes(&self, input: &RecordBatch) -> usize { + let points = self.clipped_points(input); + let mut bytes = points.saturating_mul(4 * std::mem::size_of::()); + for (index, column) in input.columns().iter().enumerate() { + // The time column of the output is built from the timestamps counted above. + if index == self.time_index { + continue; + } + bytes = bytes.saturating_add(match row_bytes(column.as_ref()) { + Some(width) => points.saturating_mul(width.saturating_add(1)), + None => column.get_buffer_memory_size(), + }); + } + // The `Arc<[ArrayRef]>` this batch is assembled from, one pointer per column. + bytes.saturating_add( + input + .num_columns() + .saturating_mul(std::mem::size_of::()), + ) + } + + /// The points [`select_instants`] decides for the series `input` holds. + /// + /// That selection clips this operator's `start..=end` to the series it reads before it walks + /// the grid, so the points it allocates for are the ones of the clipped range and not the ones + /// of the whole planned grid: a series whose samples end long before the query does reaches no + /// later point, yet stepping the whole planned range by the interval reserved the workspace of + /// every point of it, and a pool that could hold everything such a series selects refused the + /// query before selecting anything. + /// + /// The bounds, the clip and the alignment below are [`select_instants`]'s, kept the same line + /// for line because the reservation must cover what that function allocates: a point it misses + /// is memory no reservation bounds, and a point it invents is memory reserved for nothing. + /// + /// This batch holds one complete series -- [`SeriesDivide`] upstream guarantees it -- so the + /// first and last timestamp of its time column are the series bounds and reading them is + /// constant work, no matter how many samples the series holds. + fn clipped_points(&self, input: &RecordBatch) -> usize { + if self.end < self.start || self.interval <= 0 { + return 0; + } + let time = input.column(self.time_index); + if time.is_empty() { + return 0; + } + let Some((timestamps, _)) = timestamp_array_to_primitive(time) else { + return 0; + }; + let (start, end) = self.clipped_range(timestamps.values()); + self.range_points(start, end) + } + + /// This operator's grid clipped to the instants one series is selected at. + /// + /// `ticks` are that series' sample times in the operator's native tick unit, sorted and + /// non-empty, and the interval must be positive. The lower bound moves to the first instant at + /// or after the first sample and the upper bound to the last instant at or before the last + /// sample widened by the lookback, both rounded onto the grid. An instant outside the returned + /// range has no sample to select, so [`Self::scan_instants`] never walks it and nothing is + /// allocated for it. Returns `(1, 0)` -- an empty range -- when nothing is left. + /// + /// [`Self::manipulate`] and [`Self::clipped_points`] both call this. They used to carry the + /// same arithmetic twice, on the reasoning that the reservation must mirror the selection; one + /// copy is the stronger guarantee, because the two cannot drift apart at all. + fn clipped_range(&self, ticks: &[i64]) -> (Millisecond, Millisecond) { + // Shift the native-tick timeline in i128 before comparing samples. Doing this in the Arrow + // storage unit can overflow even when the shifted PromQL millisecond evaluation time is + // representable. + let scale = nanoseconds_per_native_tick(self.time_unit); + let to_nanoseconds = + |timestamp: i64| (timestamp as i128) * scale + (self.offset as i128) * 1_000_000; + let first_ns = to_nanoseconds(ticks[0]); + let last_ns = to_nanoseconds(ticks[ticks.len() - 1]); + // An exact sample remains useful with zero lookback. Otherwise the lower boundary is + // exclusive, so subtract one nanosecond from its final window. + let last_useful = if self.lookback_delta == 0 { + last_ns + } else { + last_ns + (self.lookback_delta as i128) * 1_000_000 - 1 + }; + let first_ms = (first_ns + 999_999).div_euclid(1_000_000); + let last_ms = last_useful.div_euclid(1_000_000); + let query_start = self.start as i128; + let query_end = self.end as i128; + let interval = self.interval as i128; + let max_start = first_ms.max(query_start); + let min_end = last_ms.min(query_end); + if max_start > min_end { + return (1, 0); + } + ( + (query_start + (max_start - query_start) / interval * interval) as i64, + (query_end - (query_end - min_end) / interval * interval) as i64, + ) + } + + /// The number of instants in `start..=end`, stepping by the interval. + fn range_points(&self, start: Millisecond, end: Millisecond) -> usize { + if end < start || self.interval <= 0 { + return 0; + } + usize::try_from((end as i128 - start as i128) / self.interval as i128 + 1) + .unwrap_or(usize::MAX) + } +} + +/// The bytes one selected point copies out of `column`, `None` for a column type this path does not +/// carry. +/// +/// `take` allocates one row of the column per point, so one point costs the widest row the column +/// holds: the width of its value for a fixed-width column, the longest value for a byte column, and +/// the whole row for a struct such as a native histogram -- its scalar columns, the offsets of its +/// list columns and the values those lists hold for the widest row of the column. +fn row_bytes(column: &dyn Array) -> Option { + if let Some(width) = column.data_type().primitive_width() { + return Some(width); + } + // `primitive_width` reports a `Boolean` as unknown, and its values are one byte wide. + if let DataType::Boolean = column.data_type() { + return Some(1); + } + if let Some(values) = column.as_any().downcast_ref::() { + return Some(values.offsets().lengths().max().unwrap_or(0)); + } + if let Some(values) = column.as_any().downcast_ref::() { + return Some(values.offsets().lengths().max().unwrap_or(0)); + } + let values = column.as_any().downcast_ref::()?; + Some( + values + .columns() + .iter() + .map(|child| match child.data_type() { + // One offset per row of a list column, and the values the widest row holds. + DataType::List(_) => { + std::mem::size_of::().saturating_add(widest_list_row(child)) + } + DataType::LargeList(_) => { + std::mem::size_of::().saturating_add(widest_list_row(child)) + } + // The scalars of a row are fixed width; a nested column counted at the width of the + // widest value this crate stores keeps the sum an upper bound. + _ => child + .data_type() + .primitive_width() + .unwrap_or(std::mem::size_of::()), + }) + .fold(0usize, usize::saturating_add), + ) +} + +/// The values the widest row of a list column holds, in bytes. +/// +/// A list column of a histogram row is one bucket side or one bucket list of it, so one row of it +/// is one list's values: the widest row times the width of a value bounds what a point copies. +fn widest_list_row(column: &dyn Array) -> usize { + let (widest, values) = if let Some(list) = column.as_any().downcast_ref::() { + (list.offsets().lengths().max().unwrap_or(0), list.values()) + } else if let Some(list) = column.as_any().downcast_ref::() { + (list.offsets().lengths().max().unwrap_or(0), list.values()) + } else { + return 0; + }; + widest.saturating_mul( + values + .data_type() + .primitive_width() + .unwrap_or(std::mem::size_of::()), + ) } fn reuse_constant_column(array: &Arc, len: usize) -> DataFusionResult> { @@ -925,6 +1142,8 @@ mod test { use datafusion::common::ToDFSchema; use datafusion::datasource::memory::MemorySourceConfig; use datafusion::datasource::source::DataSourceExec; + use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool, UnboundedMemoryPool}; + use datafusion::execution::runtime_env::RuntimeEnvBuilder; use datafusion::logical_expr::{ EmptyRelation, Extension, LogicalPlan, Projection, UserDefinedLogicalNodeCore, }; @@ -937,6 +1156,21 @@ mod test { TIME_INDEX_COLUMN, native_histogram, prepare_test_data, prepare_test_data_with_stale_marker, }; + /// An unbounded reservation, for the streams built outside an operator's `execute`. + fn unbounded() -> MemoryReservation { + let pool: Arc = Arc::new(UnboundedMemoryPool::default()); + MemoryConsumer::new("InstantManipulateStreamTest").register(&pool) + } + + /// A task context whose pool holds `bytes`, so a reservation larger than that is refused. + fn bounded_task_context(bytes: usize) -> Arc { + let runtime = RuntimeEnvBuilder::new() + .with_memory_pool(Arc::new(GreedyMemoryPool::new(bytes))) + .build() + .unwrap(); + Arc::new(TaskContext::default().with_runtime(Arc::new(runtime))) + } + async fn do_normalize_test( start: Millisecond, end: Millisecond, @@ -1553,8 +1787,15 @@ mod test { ); } - #[tokio::test] - async fn manipulate_should_reject_too_many_output_points() { + /// One series of one `value` column holding `rows` of `(timestamp, value)` decided over + /// `start..=end` by `interval` with `lookback`. + fn one_series( + rows: Vec<(Millisecond, f64)>, + start: Millisecond, + end: Millisecond, + interval: Millisecond, + lookback: Millisecond, + ) -> Arc { let schema = Arc::new(Schema::new(vec![ Field::new( TIME_INDEX_COLUMN, @@ -1563,24 +1804,24 @@ mod test { ), Field::new("value", DataType::Float64, true), ])); + let (timestamps, values): (Vec, Vec) = rows.into_iter().unzip(); let batch = RecordBatch::try_new( schema.clone(), vec![ - Arc::new(TimestampMillisecondArray::from(vec![0])), - Arc::new(Float64Array::from(vec![1.0])), + Arc::new(TimestampMillisecondArray::from(timestamps)), + Arc::new(Float64Array::from(values)), ], ) .unwrap(); let input = Arc::new(DataSourceExec::new(Arc::new( MemorySourceConfig::try_new(&[vec![batch]], schema, None).unwrap(), ))); - let too_many_points = MAX_INSTANT_MANIPULATE_OUTPUT_POINTS as Millisecond + 1; - let normalize_exec = Arc::new(InstantManipulateExec { + Arc::new(InstantManipulateExec { offset: 0, - start: 0, - end: too_many_points, - lookback_delta: too_many_points + 1, - interval: 1, + start, + end, + lookback_delta: lookback, + interval, time_index_column: TIME_INDEX_COLUMN.to_string(), field_column: Some("value".to_string()), reuse_tsid_column: false, @@ -1588,15 +1829,200 @@ mod test { properties: input.properties().clone(), input, metric: ExecutionPlanMetricsSet::new(), - }); - let session_context = SessionContext::default(); - let err = datafusion::physical_plan::collect(normalize_exec, session_context.task_ctx()) - .await - .unwrap_err(); + }) + } + + /// A single row of one series selected at every point of a grid `points` long, with `lookback` + /// wide enough that the row answers all of them. + fn one_row_selected_everywhere(points: Millisecond) -> Arc { + one_series(vec![(0, 1.0)], 0, points, 1, points + 1) + } + + /// A grid of a million and one points used to be refused by a point count of the kernel's own. + /// What refuses it now is the pool, which cannot hold the workspace of the selection. + #[tokio::test] + async fn manipulate_reports_the_pool_that_cannot_hold_the_workspace() { + let points = 1_000_001; + let err = datafusion::physical_plan::collect( + one_row_selected_everywhere(points), + bounded_task_context(1024), + ) + .await + .unwrap_err(); assert!( - err.to_string() - .contains("InstantManipulate output points exceed limit") + matches!(err, DataFusionError::ResourcesExhausted(_)), + "the pool did not refuse the workspace: {err}" + ); + assert!( + !err.to_string().contains("exceed limit"), + "a point limit answered in the pool's place: {err}" + ); + } + + /// The same grid, past what the removed limit allowed, is decided against a pool that holds it. + #[tokio::test] + async fn manipulate_decides_a_grid_past_the_removed_point_limit() { + let points = 1_000_001; + let batches = datafusion::physical_plan::collect( + one_row_selected_everywhere(points), + // This series' one sample answers every point of the grid under that wide lookback, so + // its clipped grid is the whole grid. The estimate of `manipulate_reserved` for this + // schema is 41 bytes per point (four `u64`, one `f64` and a validity bit), so this + // holds the million-odd points it reserves for with room to spare. + bounded_task_context(64 * 1024 * 1024), + ) + .await + .unwrap(); + + let rows: usize = batches.iter().map(|batch| batch.num_rows()).sum(); + assert_eq!( + rows, + points as usize + 1, + "the grid the removed limit refused was not decided" + ); + } + + /// A series that ends at its first sample is charged for the points it selects, not for the + /// grid of the whole query range. + /// + /// Seven days at a 60 s step is 10 081 grid points, and the whole-grid figure of this schema is + /// 41 bytes per point: 413 337 bytes, which a 256 KiB pool refuses. The series holds one sample + /// at `t=0` and its lookback is 5 min, so `select_instants` clips it to the six-point grid + /// `0..=300 s`, from which the five points `0, 60, ..., 240 s` are selected; the reservation + /// covers those six points -- 262 bytes -- and the pool holds it. Before the reservation + /// followed the clipped range, the query was refused before anything was selected. + #[tokio::test] + async fn series_that_ends_at_its_first_sample_is_not_charged_for_the_grid() { + let batches = datafusion::physical_plan::collect( + one_series( + vec![(0, 1.0)], + 0, + 7 * 24 * 60 * 60 * 1_000, + 60_000, + 5 * 60 * 1_000, + ), + bounded_task_context(256 * 1024), + ) + .await + .unwrap(); + + let timestamps: Vec = batches + .iter() + .flat_map(|batch| { + batch + .column(0) + .as_any() + .downcast_ref::() + .expect("the output time column is in milliseconds") + .values() + .to_vec() + }) + .collect(); + assert_eq!(timestamps, vec![0, 60_000, 120_000, 180_000, 240_000]); + } + + /// A series whose samples span the whole query range is still charged for the whole grid, so + /// the clipped figure is a bound on what is selected and not a reservation of nothing. + #[tokio::test] + async fn series_that_spans_the_whole_grid_is_still_charged_for_it() { + let end = 7 * 24 * 60 * 60 * 1_000; + let spanning = || one_series(vec![(0, 1.0), (end, 2.0)], 0, end, 60_000, 5 * 60 * 1_000); + + let err = datafusion::physical_plan::collect(spanning(), bounded_task_context(256 * 1024)) + .await + .unwrap_err(); + assert!( + matches!(err, DataFusionError::ResourcesExhausted(_)), + "a pool below the clipped grid's workspace did not refuse it: {err}" + ); + + let batches = + datafusion::physical_plan::collect(spanning(), bounded_task_context(1024 * 1024)) + .await + .unwrap(); + // A sample answers only the points of its own lookback window, so the two samples decide + // six rows: the five of the first 5 min and the point of the second sample. The grid they + // reach, and the one the reservation covers, is the whole 10 081 points. + let rows: usize = batches.iter().map(|batch| batch.num_rows()).sum(); + assert_eq!( + rows, 6, + "the series spanning the whole grid was not decided" + ); + } + + /// The figure `manipulate_reserved` grows is the clipped one: 262 bytes for a series that ends + /// at its first sample, 413 337 bytes for one that spans the same seven-day grid of 10 081 + /// points at a 60 s step. Per point it is the four `u64` of the two index vectors and the + /// timestamp allowance, the `f64` and validity bit `take` allocates for `value`, and one + /// `ArrayRef` per column. + #[test] + fn workspace_bytes_follows_the_clipped_grid_not_the_planned_one() { + let schema = Arc::new(Schema::new(vec![ + Field::new( + TIME_INDEX_COLUMN, + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + Field::new("value", DataType::Float64, true), + ])); + let end = 7 * 24 * 60 * 60 * 1_000; + let stream = InstantManipulateStream { + offset: 0, + start: 0, + end, + lookback_delta: 5 * 60 * 1_000, + interval: 60_000, + time_index: 0, + time_unit: TimeUnit::Millisecond, + field_indices: [Some(1), None], + tsid_index: None, + reuse_tsid_column: false, + schema: schema.clone(), + input: Box::pin( + datafusion::physical_plan::memory::MemoryStream::try_new( + vec![], + schema.clone(), + None, + ) + .unwrap(), + ), + metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), + num_series: Count::new(), + reservation: unbounded(), + }; + let series = |timestamps: Vec| { + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(TimestampMillisecondArray::from(timestamps.clone())), + Arc::new(Float64Array::from(vec![1.0; timestamps.len()])), + ], + ) + .unwrap() + }; + + // One sample at `t=0` under a 5 min lookback clips to the six points `0, 60, ..., 300 s`; + // the exclusive lower bound leaves five of them selected. + let short = series(vec![0]); + assert_eq!(stream.clipped_points(&short), 6); + assert_eq!( + stream.workspace_bytes(&short), + 6 * 32 + 6 * 9 + 2 * std::mem::size_of::() + ); + // Samples at both ends reach every point of the planned grid, and are charged for it. + let long = series(vec![0, end]); + assert_eq!(stream.clipped_points(&long), 10_081); + assert_eq!( + stream.workspace_bytes(&long), + 10_081 * 32 + 10_081 * 9 + 2 * std::mem::size_of::() + ); + // An input without samples selects nothing and reserves nothing beyond the batch's columns. + let empty = series(vec![]); + assert_eq!(stream.clipped_points(&empty), 0); + assert_eq!( + stream.workspace_bytes(&empty), + 2 * std::mem::size_of::() ); } @@ -1921,6 +2347,7 @@ mod test { ), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), }; let output = stream.manipulate(input).unwrap(); @@ -1988,6 +2415,7 @@ mod test { ), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), }; let output = stream.manipulate(input).unwrap(); @@ -2053,6 +2481,7 @@ mod test { input: Box::pin(input), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), }; let waker = noop_waker_ref(); let mut cx = Context::from_waker(waker); @@ -2136,6 +2565,7 @@ mod test { ), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), }; let output = stream.manipulate(input).unwrap(); @@ -2208,6 +2638,7 @@ mod test { ), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), }; let output = stream.manipulate(input).unwrap(); assert_eq!(output.num_rows(), 1); diff --git a/src/promql/src/extension_plan/instant_manipulate/native.rs b/src/promql/src/extension_plan/instant_manipulate/native.rs index f7df2a5feeb..ea2a28c2054 100644 --- a/src/promql/src/extension_plan/instant_manipulate/native.rs +++ b/src/promql/src/extension_plan/instant_manipulate/native.rs @@ -27,7 +27,7 @@ use datatypes::series::samples::{SampleRef, Samples, SeriesFields, SeriesSchema} mod tests; use crate::extension_plan::instant_manipulate::InstantManipulate; -use crate::instant_eval::{MAX_INSTANT_MANIPULATE_OUTPUT_POINTS, select_instants}; +use crate::instant_eval::select_instants; #[derive(Clone)] enum Column { @@ -160,6 +160,10 @@ impl NativeInstantMapping { } /// Estimates the clipped evaluation grid for complete source timestamp bounds. + /// + /// This count is what the caller reserves the workspace of a selection for before it selects, + /// so nothing but the reservation bounds the grid: a grid too long to index is refused as + /// exhausted rather than by a point limit of its own. pub(crate) fn estimated_points(&self, first: i64, last: i64) -> DataFusionResult { let first = first as i128; let last = last as i128; @@ -185,11 +189,6 @@ impl NativeInstantMapping { } else { 0 }; - if points > MAX_INSTANT_MANIPULATE_OUTPUT_POINTS as i128 { - return Err(DataFusionError::Execution(format!( - "InstantManipulate output points exceed limit: {points} > {MAX_INSTANT_MANIPULATE_OUTPUT_POINTS}" - ))); - } usize::try_from(points).map_err(|_| { DataFusionError::ResourcesExhausted("native instant point count overflow".into()) }) diff --git a/src/promql/src/extension_plan/instant_manipulate/native_exec.rs b/src/promql/src/extension_plan/instant_manipulate/native_exec.rs index 383d341b259..95b7adc33ff 100644 --- a/src/promql/src/extension_plan/instant_manipulate/native_exec.rs +++ b/src/promql/src/extension_plan/instant_manipulate/native_exec.rs @@ -16,7 +16,7 @@ use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; -use common_query::series_frame::{raw_series_frame_ordering, unpack_raw_series}; +use common_query::series_frame::{ensure_frame_matches_label_source, unpack_raw_series}; use datafusion::arrow::array::Array; use datafusion::arrow::datatypes::SchemaRef; use datafusion::arrow::record_batch::RecordBatch; @@ -31,7 +31,6 @@ use datafusion::physical_plan::{ DisplayAs, DisplayFormatType, ExecutionPlan, PhysicalExpr, PlanProperties, RecordBatchStream, SendableRecordBatchStream, }; -use datatypes::series::SeriesOrdering; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{DenseSamplesRef, Samples, SeriesFields, SeriesSchema}; use futures::{Stream, ready}; @@ -86,16 +85,16 @@ impl InstantManipulate { return Ok(None); } let declared_schema = input.schema(); - let ordering = raw_series_frame_ordering(declared_schema.as_ref())?; - // Validate the declared raw-frame schema without executing the child. + // Validate the declared raw-frame schema without executing the child. The frame states its + // own label layout, so the plan's binding is checked against that declaration rather than + // supplied to the decoder. + ensure_frame_matches_label_source(declared_schema.as_ref(), &label_source)?; unpack_raw_series( Box::pin(RecordBatchStreamAdapter::new( declared_schema.clone(), futures::stream::empty(), )), - series_schema.clone(), - label_source.clone(), - ordering, + Some(series_schema.clone()), )?; Ok(Some(Arc::new(NativeInstantManipulateExec::new( input, @@ -103,7 +102,6 @@ impl InstantManipulate { declared_schema, series_schema, label_source, - ordering, )))) } } @@ -114,7 +112,6 @@ struct NativeInstantManipulateExec { raw_schema: SchemaRef, series_schema: Arc, label_source: RawLabelSource, - ordering: SeriesOrdering, properties: Arc, } impl NativeInstantManipulateExec { @@ -124,7 +121,6 @@ impl NativeInstantManipulateExec { raw_schema: SchemaRef, series_schema: Arc, label_source: RawLabelSource, - ordering: SeriesOrdering, ) -> Self { let properties = Arc::new(PlanProperties::new( EquivalenceProperties::new(mapping.schema()), @@ -138,7 +134,6 @@ impl NativeInstantManipulateExec { raw_schema, series_schema, label_source, - ordering, properties, } } @@ -190,7 +185,6 @@ impl ExecutionPlan for NativeInstantManipulateExec { self.raw_schema.clone(), self.series_schema.clone(), self.label_source.clone(), - self.ordering, ))) } fn execute( @@ -204,12 +198,10 @@ impl ExecutionPlan for NativeInstantManipulateExec { )); } let frame = self.input.execute(partition, context.clone())?; - let source = unpack_raw_series( - frame, - self.series_schema.clone(), - self.label_source.clone(), - self.ordering, - )?; + // The child's frames state their own label layout; the plan's binding is an expectation + // checked against that declaration, not a decode input. + ensure_frame_matches_label_source(frame.schema().as_ref(), &self.label_source)?; + let source = unpack_raw_series(frame, Some(self.series_schema.clone()))?; let source = materialize_raw_labels(source) .map_err(|e| DataFusionError::Execution(e.to_string()))?; let reservation = @@ -311,6 +303,13 @@ impl NativeInstantStream { n.checked_add(field_count.checked_mul(std::mem::size_of::>())?) }) .and_then(|n| { + // Per point of the clipped grid, the most `select_instants` and the mapping of its + // selection can allocate: 24 bytes per output column, the labels of the row, and + // 48 for the two vectors the selection returns and the time column it is mapped + // into -- a `u64` source row index, an `i64` evaluation timestamp and the `i64` + // of the output column, at 8 bytes each. The point count is what stands in for + // the point limit `estimated_points` used to carry, so the selection arrays are + // reserved for here rather than refused there. points .checked_mul( self.schema diff --git a/src/promql/src/extension_plan/instant_manipulate/typed_tests.rs b/src/promql/src/extension_plan/instant_manipulate/typed_tests.rs index f2949eb3ec1..97f97a095d4 100644 --- a/src/promql/src/extension_plan/instant_manipulate/typed_tests.rs +++ b/src/promql/src/extension_plan/instant_manipulate/typed_tests.rs @@ -22,6 +22,7 @@ use datafusion::arrow::record_batch::RecordBatch; use datafusion::datasource::memory::MemorySourceConfig; use datafusion::datasource::source::DataSourceExec; use datafusion::execution::context::SessionContext; +use datafusion::execution::memory_pool::{MemoryPool, UnboundedMemoryPool}; use datafusion::physical_plan::memory::MemoryStream; use datatypes::arrow::buffer::ScalarBuffer; use datatypes::series::raw::RawTimestamps; @@ -32,6 +33,12 @@ use crate::extension_plan::range_manipulate::ScanNanoseconds; use crate::extension_plan::test_util::TIME_INDEX_COLUMN; use crate::instant_eval::select_instants; +/// An unbounded reservation, for the streams built outside an operator's `execute`. +fn unbounded() -> MemoryReservation { + let pool: Arc = Arc::new(UnboundedMemoryPool::default()); + MemoryConsumer::new("InstantManipulateStreamTest").register(&pool) +} + fn stream( batch: &RecordBatch, start: i64, @@ -56,6 +63,7 @@ fn stream( input: Box::pin(MemoryStream::try_new(vec![], schema, None).unwrap()), metric: BaselineMetrics::new(&ExecutionPlanMetricsSet::new(), 0), num_series: Count::new(), + reservation: unbounded(), } } diff --git a/src/promql/src/grid_series.rs b/src/promql/src/grid_series.rs index ce24cae6af4..42a87e131ff 100644 --- a/src/promql/src/grid_series.rs +++ b/src/promql/src/grid_series.rs @@ -24,7 +24,7 @@ use datafusion::error::{DataFusionError, Result}; use datatypes::series::MetricName; use datatypes::series::histogram::HistogramBatchBuilder; use datatypes::series::samples::{ - HistogramSamples, RowCursor, SampleRef, Samples, Series, SeriesFields, SeriesSchema, + HistogramSamples, SampleRef, Samples, Series, SeriesFields, SeriesSchema, }; use datatypes::series::time::{EvalGrid, GridSpan, TimeAxis}; @@ -320,7 +320,15 @@ pub(crate) fn label_set(metric: &MetricName) -> LabelSet { } /// Merges series that share a label set into one, as Prometheus does after a function that -/// rewrites labels or drops `__name__`; two of them holding one field at one point fail. +/// rewrites labels or drops `__name__`; two of them holding one field at one point fail, whether +/// the samples are floats or native histograms. +/// +/// The merge rearranges the payloads instead of rewriting them: a merged row takes the value its +/// member carries at that position -- a float, a NaN, or a native histogram -- and a field another +/// field's row does not fill keeps a null there. The values keep their storage: a run of rows one +/// member holds in one dense segment is handed out as a [`Samples::slice`] of that segment, so a +/// histogram table is shared rather than copied. The result costs one row per position holding a +/// value, plus the rows the other fields fill, and nothing for a position of the grid holding none. pub fn merge_same_labelset(schema: &Arc, series: Vec) -> Result> { let mut groups: HashMap> = HashMap::with_capacity(series.len()); let mut collided = false; @@ -339,111 +347,277 @@ pub fn merge_same_labelset(schema: &Arc, series: Vec) -> R return Ok(series); } + // The merged axis and the fields placed on it are the schema's grid: a member of another grid + // would land its rows at positions naming other timestamps. + let grid = schema.eval_grid.clone().ok_or_else(|| { + DataFusionError::Execution("grid series schema requires an evaluation grid".into()) + })?; let mut series: Vec> = series.into_iter().map(Some).collect(); let mut output = Vec::with_capacity(groups.len()); - let mut builder = GridSeriesBuilder::try_new(schema.clone())?; - // `(absolute position, member slot, compact row)`, sorted by position before merging. The - // slot indexes `members`, so the merge below picks the member's cursor directly instead of - // searching for the member per row. + // The groups in the order their first member arrived: a `HashMap` would hand the merged series + // back in an order that changes between runs, and the root's chunk stream exposes exactly this + // order, so two executions of one query could report their series differently. + let mut groups: Vec<(LabelSet, Vec)> = groups.into_iter().collect(); + groups.sort_unstable_by_key(|(_, members)| members[0]); + // `(absolute position, member slot, compact row)`, sorted by position before merging. The slot + // indexes the group's fragments, so a row names its member without a search. let mut rows: Vec<(usize, usize, usize)> = Vec::new(); - let mut values = vec![None; schema.fields.len()]; - let mut merged = vec![None; schema.fields.len()]; + // The position of each merged row, ascending, with the positions several fragments share held + // once. + let mut positions: Vec = Vec::new(); for (labels, members) in groups { if let [member] = members[..] { output.extend(series[member].take()); continue; } - builder.begin_series(); - rows.clear(); - for (member_slot, &member) in members.iter().enumerate() { - let member_series = series[member] - .as_ref() - .expect("each series is in one group"); - if member_series.time.grid().is_none() { + // Validate every fragment before indexing it: the merge reads its fields and places its + // rows on the schema's grid. + let mut fragments: Vec<&Series> = Vec::with_capacity(members.len()); + for &member in &members { + let fragment = series[member].as_ref().ok_or_else(|| { + DataFusionError::Execution("each series is in exactly one label set".into()) + })?; + let Some(fragment_grid) = fragment.time.grid() else { return Err(DataFusionError::Execution( "merging requires evaluated series".into(), )); + }; + if fragment_grid.spec() != grid.spec() { + return Err(DataFusionError::Execution( + "merging requires one evaluation grid".into(), + )); } - // The axis names the positions this member holds, and one cursor per field reads its - // values in the same order, so a member of several runs costs its rows. - let mut cursors: Vec> = member_series + if fragment.fields.len() != schema.fields.len() { + return Err(DataFusionError::Execution(format!( + "merging requires {} fields, a fragment holds {}", + schema.fields.len(), + fragment.fields.len() + ))); + } + // The alignment is checked before a row is read: a field of another length would either + // run off the axis or leave values the axis never reaches. + let time_len = fragment.time.len(); + if let Some((field, samples)) = fragment .fields .iter() - .map(|field| field.cursor()) - .collect(); - for (point, row) in member_series.time.positions() { - if read_row(&mut cursors, row, &mut values)? { - rows.push((point, member_slot, row)); + .enumerate() + .find(|(_, samples)| samples.len() != time_len) + { + return Err(DataFusionError::Execution(format!( + "merging requires every field to hold the {time_len} rows of its axis, field \ + {field} holds {}", + samples.len() + ))); + } + fragments.push(fragment); + } + rows.clear(); + for (member_slot, fragment) in fragments.iter().enumerate() { + // One forward reader per field: the axis names the rows that exist, and a row holding + // no value in any field is not a merged row. + let mut readers: Vec> = + fragment.fields.iter().map(FieldReader::new).collect(); + for (position, row) in fragment.time.positions() { + let mut present = false; + for reader in &mut readers { + let (segment, offset) = reader.locate(row)?; + present |= !matches!(segment.sample(offset), SampleRef::Missing); + } + if present { + rows.push((position, member_slot, row)); } } } - // By position, then by member slot: the slots follow the order `members` was built in, so - // the merge below still walks each member's rows forward and its cursors never go back. + // By position, then by member slot: the slots follow the order the fragments were collected + // in, so a member's rows only go forward and its readers never go back. rows.sort_unstable(); - // One forward cursor per member and field, so re-reading a row costs neither a chunk scan - // nor an allocation. - let mut cursors: Vec>> = members - .iter() - .map(|&member| { - let member_series = series[member] - .as_ref() - .expect("each series is in one group"); - member_series - .fields - .iter() - .map(|field| field.cursor()) - .collect() - }) - .collect(); - let mut start = 0; - while start < rows.len() { - let point = rows[start].0; - let end = start + rows[start..].partition_point(|&(p, _, _)| p == point); - // Rows of one point merge field by field; only a field present twice collides. - merged.iter_mut().for_each(|value| *value = None); - for &(_, member_slot, row) in &rows[start..end] { - read_row(&mut cursors[member_slot], row, &mut values)?; - for (slot, value) in merged.iter_mut().zip(&values) { - if value.is_some() { - if slot.is_some() { - let labels: Vec<_> = labels - .iter() - .map(|(name, value)| format!("{name}=\"{value}\"")) - .collect(); - return Err(DataFusionError::Execution(format!( - "vector cannot contain metrics with the same labelset {{{}}}", - labels.join(", ") - ))); - } - *slot = *value; - } - } - } - builder.push(point, &merged)?; - start = end; + positions.clear(); + positions.extend(rows.iter().map(|&(position, _, _)| position)); + positions.dedup(); + let mut fields = Vec::with_capacity(schema.fields.len()); + for field in 0..schema.fields.len() { + let mut readers: Vec> = fragments + .iter() + .map(|fragment| FieldReader::new(&fragment.fields.as_slice()[field])) + .collect(); + fields.push(merge_field(&mut readers, &rows, &labels)?); } - let metric = series[members[0]].as_ref().unwrap().metric.clone(); - output.extend(builder.finish(metric)?); + // The runs are all the axis remembers about the holes, so a sparse merge stores one span + // per run and no row for the positions between them. + let time = + TimeAxis::from_grid_spans(grid.clone(), spans_of(&positions)).map_err(external)?; + let fields = SeriesFields::try_from_vec(fields).map_err(external)?; + // The first fragment names the output, as it always has: a label set ignores empty values, + // so the metric of the first member is the one the merged series keeps. + let metric = fragments[0].metric.clone(); + output.push(Series::try_new(schema, metric, time, fields).map_err(external)?); } Ok(output) } -/// Reads the float fields of `row` through the member's cursors; returns whether any is present. -fn read_row(cursors: &mut [RowCursor<'_>], row: usize, values: &mut [Option]) -> Result { - let mut present = false; - for (value, cursor) in values.iter_mut().zip(cursors) { - *value = match cursor.at(row).expect("the axis and the fields are aligned") { - SampleRef::Missing => None, - SampleRef::Float(value) => Some(value), - SampleRef::Histogram(_) => { - return Err(DataFusionError::NotImplemented( - "merging histogram series with the same label set".into(), - )); +/// Builds one field of the merged series, one sample per merged row. +/// +/// The rows are walked in output order. A row holding a value of this field in one member continues +/// the run of rows that member holds in one dense segment, and a run is handed out as one +/// [`Samples::slice`]; the rows another field fills become one null run, so the field stays aligned +/// with the merged axis while sharing the values it carries. +fn merge_field( + readers: &mut [FieldReader<'_>], + rows: &[(usize, usize, usize)], + labels: &LabelSet, +) -> Result { + let mut chunks: Vec = Vec::new(); + // The run of values the last merged row continued, if any. + let mut run: Option> = None; + // Merged rows this field leaves empty, waiting for the null run that carries them. + let mut missing = 0; + let mut start = 0; + while start < rows.len() { + let position = rows[start].0; + let mut end = start; + // At most one member may hold this field here; the second value is the collision Prometheus + // rejects, since the two label sets became one. + let mut value: Option<(usize, &Samples, usize)> = None; + while end < rows.len() && rows[end].0 == position { + let (_, member, row) = rows[end]; + let (segment, offset) = readers[member].locate(row)?; + if !matches!(segment.sample(offset), SampleRef::Missing) { + if value.is_some() { + let labels: Vec<_> = labels + .iter() + .map(|(name, value)| format!("{name}=\"{value}\"")) + .collect(); + return Err(DataFusionError::Execution(format!( + "vector cannot contain metrics with the same labelset {{{}}}", + labels.join(", ") + ))); + } + value = Some((member, segment, offset)); } - }; - present |= value.is_some(); + end += 1; + } + match value { + Some((member, segment, offset)) => { + if let Some(run) = &mut run + && run.continues(member, segment, offset) + { + run.len += 1; + } else { + if let Some(run) = run.take() { + run.push(&mut chunks)?; + } + push_missing(&mut chunks, &mut missing); + run = Some(FieldRun { + member, + segment, + offset, + len: 1, + }); + } + } + None => { + if let Some(run) = run.take() { + run.push(&mut chunks)?; + } + missing += 1; + } + } + start = end; + } + if let Some(run) = run { + run.push(&mut chunks)?; + } + push_missing(&mut chunks, &mut missing); + Samples::try_from_chunks(chunks).map_err(external) +} + +/// A run of merged rows one member field fills out of one dense segment, without copying values. +/// +/// The run grows only while the next merged row is the same member's next row in the same segment, +/// so handing it out is one [`Samples::slice`] of that segment: the payload stays where it is, and +/// the rows a hole skipped are not part of the run. +struct FieldRun<'a> { + /// The member slot the run reads, which indexes the [`FieldReader`]s of the field. + member: usize, + /// The dense segment holding the run's values. + segment: &'a Samples, + /// Offset of the run's first row inside `segment`. + offset: usize, + /// How many rows of `segment` the run covers. + len: usize, +} + +impl FieldRun<'_> { + /// Returns whether the value at `offset` of `segment` continues the run: the same member's next + /// row in the same dense segment. + fn continues(&self, member: usize, segment: &Samples, offset: usize) -> bool { + self.member == member + && std::ptr::eq(self.segment, segment) + && self.offset + self.len == offset + } + + /// Pushes the run as one slice of its dense segment. + fn push(self, chunks: &mut Vec) -> Result<()> { + chunks.push( + self.segment + .slice(self.offset, self.len) + .map_err(external)?, + ); + Ok(()) + } +} + +/// Reads one field's rows by locating the dense segment each row sits in. +/// +/// [`Samples::sample`] rescans a chunked field from its first segment on every row, so reading a +/// field row by row would cost a scan over the segments before it. The rows a merge reads only go +/// forward, so this walks the segments once and returns the segment with the offset of the row +/// inside it, which is what a [`FieldRun`] slices. +struct FieldReader<'a> { + segments: &'a [Samples], + /// Index of the segment `start` belongs to. + segment: usize, + /// Logical row the current segment starts at. + start: usize, +} + +impl<'a> FieldReader<'a> { + /// Creates a reader at the first row of `samples`. + fn new(samples: &'a Samples) -> Self { + Self { + segments: samples.segments(), + segment: 0, + start: 0, + } + } + + /// Returns the dense segment holding `row` and the offset of `row` inside it. + /// + /// Rows must not go backwards. A row past the field is an error rather than a panic: a member is + /// only read after its fields are validated to align with its axis. + fn locate(&mut self, row: usize) -> Result<(&'a Samples, usize)> { + while let Some(segment) = self.segments.get(self.segment) { + if let Some(offset) = row + .checked_sub(self.start) + .filter(|offset| *offset < segment.len()) + { + return Ok((segment, offset)); + } + self.segment += 1; + self.start += segment.len(); + } + Err(DataFusionError::Execution( + "merging requires each field to be row-aligned with its time axis".into(), + )) + } +} + +/// Pushes the merged rows a field leaves empty as one null run. +fn push_missing(chunks: &mut Vec, missing: &mut usize) { + if *missing != 0 { + chunks.push(Samples::from_float(Float64Array::new_null(*missing))); + *missing = 0; } - Ok(present) } pub(crate) fn external(error: datatypes::error::Error) -> DataFusionError { diff --git a/src/promql/src/grid_series/tests.rs b/src/promql/src/grid_series/tests.rs index 59e833e3fd3..7692513a036 100644 --- a/src/promql/src/grid_series/tests.rs +++ b/src/promql/src/grid_series/tests.rs @@ -15,17 +15,23 @@ use std::mem::size_of; use std::sync::Arc; -use datatypes::series::samples::{DenseSamplesRef, FieldSpec, SampleRef, SeriesSchema}; -use datatypes::series::time::{EvalGrid, GridSpec}; +use datafusion::arrow::array::{Float64Array, UInt32Array}; +use datatypes::series::histogram::{HistogramBatch, HistogramBatchBuilder}; +use datatypes::series::samples::{ + DenseSamplesRef, FieldSpec, HistogramSamples, SampleRef, Samples, Series, SeriesFields, + SeriesSchema, +}; +use datatypes::series::time::{EvalGrid, GridSpan, GridSpec, TimeAxis}; use datatypes::series::{Label, MetricName}; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use super::reference::{ - Labels, Points, assert_pieces_eq, assert_result_eq, points_of, random_dense, run_ranges, - series_of, stitch, + Labels, OwnedHistogram, Points, assert_pieces_eq, assert_result_eq, metric_of, points_of, + random_dense, run_ranges, series_of, stitch, }; -use super::{GridSeriesBuilder, merge_same_labelset}; +use super::{GridSample, GridSeriesBuilder, merge_same_labelset}; +use crate::histogram_eval::float_histogram::FloatHistogram; fn grid_schema(fields: usize, grid_len: u64) -> Arc { Arc::new(SeriesSchema { @@ -206,6 +212,93 @@ fn merge_combines_disjoint_fields_at_one_point() { ); } +/// Native histograms merge like floats: members of one label set combine by field and position, +/// histograms and floats keep their type, and a field held twice at one point collides whatever +/// the sample types. +#[test] +fn merge_keeps_histograms_and_rejects_any_doubled_field() { + #[derive(Clone, Copy)] + enum Value { + Float(f64), + Histogram(usize), + } + + let schema = grid_schema(2, 3); + let histograms = [ + OwnedHistogram::parse("0/0/0/3ff0000000000000/3ff0000000000000/0/1_1/3ff0000000000000///"), + OwnedHistogram::parse("0/0/0/4000000000000000/4008000000000000/1/0_1/4000000000000000///"), + ]; + let histogram = + |index: usize| FloatHistogram::try_from_view(&histograms[index].view()).unwrap(); + let sample = |value: Option| { + value.map(|value| match value { + Value::Float(float) => GridSample::Float(float), + Value::Histogram(index) => GridSample::Histogram(Box::new(histogram(index))), + }) + }; + let series = |rows: &[(usize, [Option; 2])]| { + let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); + for &(point, row) in rows { + builder + .push_samples(point, &[sample(row[0]), sample(row[1])]) + .unwrap(); + } + builder.finish(metric(0)).unwrap().unwrap() + }; + let describe = |series: &Series, field: usize| -> Vec<(usize, String)> { + let samples = &series.fields.as_slice()[field]; + series + .time + .positions() + .filter_map(|(point, row)| match samples.sample(row) { + SampleRef::Missing => None, + SampleRef::Float(value) => Some((point, format!("{value}"))), + SampleRef::Histogram(view) => Some(( + point, + format!("{:?}", FloatHistogram::try_from_view(&view).unwrap()), + )), + }) + .collect() + }; + + let merged = merge_same_labelset( + &schema, + vec![ + series(&[ + (0, [Some(Value::Histogram(0)), None]), + (2, [Some(Value::Float(1.0)), None]), + ]), + series(&[ + (0, [None, Some(Value::Float(2.0))]), + (1, [Some(Value::Histogram(1)), None]), + ]), + ], + ) + .unwrap(); + assert_eq!(merged.len(), 1); + assert_eq!( + describe(&merged[0], 0), + [ + (0, format!("{:?}", histogram(0))), + (1, format!("{:?}", histogram(1))), + (2, "1".to_string()), + ] + ); + assert_eq!(describe(&merged[0], 1), [(0, "2".to_string())]); + + for second in [Value::Float(3.0), Value::Histogram(1)] { + let error = merge_same_labelset( + &schema, + vec![ + series(&[(1, [Some(Value::Histogram(0)), None])]), + series(&[(1, [Some(second), None])]), + ], + ) + .unwrap_err(); + assert!(error.to_string().contains("same labelset"), "{error}"); + } +} + /// Two values a million positions apart are one series with two rows, addressed by two runs: the /// positions between them cost neither a row nor a value buffer sized by the span. #[test] @@ -479,6 +572,325 @@ fn finish_hands_out_only_the_rows_pushed_after_a_prefix_flush() { assert!(!builder.has_pending()); } +// --------------------------------------------------------------------------------------------- +// Merging: one label set handed over in several fragments. +// +// A runtime hands one label set over as fragments, and the merge is the only place that puts them +// back into one series. What these tests pin is that the values move without being rebuilt: the +// merged series shares the fragments' payloads, keeps native histograms and NaNs, and still costs +// one row per position holding a value. +// --------------------------------------------------------------------------------------------- + +/// The start of the float buffer a dense float segment reads, to show a merge shared the buffer +/// instead of copying it. +fn float_ptr(segment: &Samples) -> *const f64 { + match segment.dense() { + Some(DenseSamplesRef::Float(values)) => values.values().as_ptr(), + _ => panic!("expected a dense float segment"), + } +} + +type FragmentRow<'a> = (usize, Option, Option<&'a str>, Option); + +/// A two-field fragment: field 0 holds a float or a native histogram, field 1 a float. +/// +/// `rows` holds `(position, field 0 float, field 0 histogram, field 1 float)` in ascending position +/// order; a row holds a value when some field has one at that position. +fn fragment_with_histograms( + schema: &Arc, + labels: &Labels, + rows: &[FragmentRow<'_>], +) -> (Series, Arc) { + let mut batch = HistogramBatchBuilder::new(); + let mut spans: Vec = Vec::new(); + let mut floats = Vec::with_capacity(rows.len()); + let mut indices = Vec::with_capacity(rows.len()); + let mut seconds = Vec::with_capacity(rows.len()); + for &(position, float, histogram, second) in rows { + match spans.last_mut() { + Some(span) if span.start() + span.len() == position => { + *span = GridSpan::new(span.start(), span.len() + 1); + } + _ => spans.push(GridSpan::new(position, 1)), + } + floats.push(float); + indices.push(histogram.map(|text| { + batch + .push(OwnedHistogram::parse(text).view()) + .expect("the fixture histogram is valid") + })); + seconds.push(second); + } + let batch = batch.finish().expect("an empty histogram table is valid"); + let histograms = HistogramSamples::try_new(UInt32Array::from(indices), batch.clone()).unwrap(); + let mixed = Samples::try_from_mixed(Float64Array::from(floats), histograms).unwrap(); + let fields = SeriesFields::try_from_vec(vec![ + mixed, + Samples::from_float(Float64Array::from(seconds)), + ]) + .unwrap(); + let time = TimeAxis::from_grid_spans(schema.eval_grid.clone().unwrap(), spans).unwrap(); + ( + Series::try_new(schema, metric_of(labels), time, fields).unwrap(), + batch, + ) +} + +/// Two fragments a boundary kept apart -- the first holding position 0, the second positions 1 and +/// 2 -- become one series of one run, each fragment's rows at its own positions. +#[test] +fn merge_of_adjacent_fragments_is_one_run() { + let schema = grid_schema(1, 4); + let labels = Labels::from([("job".to_string(), "x".to_string())]); + let first = series_of(&schema, &labels, &Points::from([(0, 1f64.to_bits())])).unwrap(); + let second = series_of( + &schema, + &labels, + &Points::from([(1, 2f64.to_bits()), (2, 3f64.to_bits())]), + ) + .unwrap(); + let first_ptr = float_ptr(&first.fields.as_slice()[0].segments()[0]); + let second_ptr = float_ptr(&second.fields.as_slice()[0].segments()[0]); + + let merged = merge_same_labelset(&schema, vec![first, second]).unwrap(); + assert_eq!(merged.len(), 1, "one label set, one merged series"); + assert_eq!(merged[0].time.len(), 3, "one row per value"); + assert_eq!( + run_ranges(&merged[0]), + vec![(0, 3)], + "fragments holding adjacent positions share one run" + ); + assert_eq!( + points_of(&merged[0]), + Points::from([ + (0, 1f64.to_bits()), + (1, 2f64.to_bits()), + (2, 3f64.to_bits()), + ]) + ); + // One slice per fragment: the second fragment's two rows are one slice of its own buffer, and + // neither slice copies a value. + let chunks = merged[0].fields.as_slice()[0].segments(); + assert_eq!(chunks.len(), 2, "one slice per fragment"); + assert_eq!(float_ptr(&chunks[0]), first_ptr); + assert_eq!(float_ptr(&chunks[1]), second_ptr); + assert_eq!(chunks[1].len(), 2, "the fragment's two rows are one slice"); +} + +/// Native histograms, floats, NaNs and complementary fields merge into one aligned series, and +/// every value keeps the storage it already had: the histogram table and the float buffers are +/// shared, not rebuilt. +#[test] +fn merge_keeps_native_histograms_and_complementary_fields_without_copying_them() { + let schema = grid_schema(2, 4); + let labels = Labels::from([("job".to_string(), "x".to_string())]); + let histogram = "0/0/0/3ff0000000000000/3ff0000000000000/0/1_1/3ff0000000000000///"; + // The first fragment holds a native histogram in field 0 and a float in field 1 at position 0. + let (first, first_batch) = + fragment_with_histograms(&schema, &labels, &[(0, None, Some(histogram), Some(1.0))]); + // The second holds field 0's float at position 1 and field 1's NaN at position 2, leaving the + // other field missing at each. + let (second, _) = fragment_with_histograms( + &schema, + &labels, + &[(1, Some(2.0), None, None), (2, None, None, Some(f64::NAN))], + ); + let second_field1 = float_ptr(&second.fields.as_slice()[1].segments()[0]); + + let merged = merge_same_labelset(&schema, vec![first, second]).unwrap(); + assert_eq!(merged.len(), 1); + assert_eq!( + run_ranges(&merged[0]), + vec![(0, 3)], + "positions 0, 1 and 2 are one run" + ); + assert_eq!(merged[0].time.len(), 3); + assert_eq!(merged[0].fields.len(), 2); + + // Field 0: the histogram, the float, and a null at the position only field 1 fills. + let field0 = merged[0].fields.as_slice()[0].segments(); + assert_eq!(field0.len(), 3, "a histogram, a float, and one null run"); + let DenseSamplesRef::Mixed(mixed) = field0[0].dense().unwrap() else { + panic!("the histogram lost its mixed column") + }; + assert!( + Arc::ptr_eq(mixed.histograms().batch(), &first_batch), + "the histogram table was rebuilt" + ); + assert!( + matches!(field0[0].sample(0), SampleRef::Histogram(view) if view.count == 1.0), + "the histogram did not survive as a histogram" + ); + assert!( + matches!(field0[1].sample(0), SampleRef::Float(value) if value.to_bits() == 2f64.to_bits()), + "field 0 lost the other fragment's float" + ); + assert!( + matches!(field0[2].sample(0), SampleRef::Missing), + "field 0 filled a position only field 1 holds" + ); + + // Field 1: its value, the null field 0 fills, and the NaN, which is a value like any other. + let field1 = merged[0].fields.as_slice()[1].segments(); + assert_eq!(field1.len(), 3, "a float, one null run, and a NaN"); + assert!( + matches!(field1[0].sample(0), SampleRef::Float(value) if value.to_bits() == 1f64.to_bits()) + ); + assert!(matches!(field1[1].sample(0), SampleRef::Missing)); + assert!( + matches!(field1[2].sample(0), SampleRef::Float(value) if value.is_nan()), + "the NaN was dropped" + ); + // The NaN came out as a slice of the second fragment's buffer, one row in. + assert_eq!( + float_ptr(&field1[2]), + second_field1.wrapping_add(1), + "the NaN was copied instead of sliced" + ); + + // Two fragments holding one field at one position still collide, histograms included. + let at_zero = + || fragment_with_histograms(&schema, &labels, &[(0, None, Some(histogram), None)]).0; + assert!( + merge_same_labelset(&schema, vec![at_zero(), at_zero()]).is_err(), + "two histograms of one field at one position merged" + ); +} + +/// A merge over many fragments of many segments keeps one row per value: a million-position grid +/// costs a span per run and a sample per value, and locating a row never rescans the segments +/// before it. +#[test] +fn merge_of_many_segments_keeps_one_row_per_value() { + const GRID: usize = 1_000_000; + const SEGMENTS: usize = 1_000; + let schema = grid_schema(1, GRID as u64); + let labels = Labels::from([("job".to_string(), "x".to_string())]); + // A fragment of one value per segment, one chunk per value, the segments a thousand positions + // apart. + let fragment = |offset: usize| { + let mut chunks = Vec::with_capacity(SEGMENTS); + let mut spans = Vec::with_capacity(SEGMENTS); + for segment in 0..SEGMENTS { + let position = offset + segment * (GRID / SEGMENTS); + chunks.push(Samples::from_float(Float64Array::from(vec![ + position as f64, + ]))); + spans.push(GridSpan::new(position, 1)); + } + let time = TimeAxis::from_grid_spans(schema.eval_grid.clone().unwrap(), spans).unwrap(); + let fields = SeriesFields::one(Samples::try_from_chunks(chunks).unwrap()); + Series::try_new(&schema, metric_of(&labels), time, fields).unwrap() + }; + + let merged = merge_same_labelset(&schema, vec![fragment(0), fragment(1)]).unwrap(); + assert_eq!(merged.len(), 1); + let series = &merged[0]; + assert_eq!( + series.time.grid().unwrap().timestamps().len(), + GRID, + "the grid really holds a million positions" + ); + assert_eq!(series.time.len(), 2 * SEGMENTS, "one row per value"); + assert_eq!( + series.time.runs().count(), + SEGMENTS, + "two adjacent values per segment are one run, and the holes between are spans" + ); + assert_eq!(run_ranges(series)[0], (0, 2)); + let field = &series.fields.as_slice()[0]; + assert_eq!( + field.len(), + 2 * SEGMENTS, + "the field holds one sample per merged row" + ); + assert_eq!( + field.segments().len(), + 2 * SEGMENTS, + "one chunk per value: no hole was densified into the payload" + ); + assert!(field.segments().iter().all(|chunk| chunk.len() == 1)); + assert_eq!( + points_of(series).len(), + 2 * SEGMENTS, + "every value survived and no hole became one" + ); +} + +/// A label set whose fragments all closed without a value still yields one series: a runtime that +/// closes a label set needs one answer per set, and an empty grid series is the legal one. +#[test] +fn merge_of_empty_fragments_yields_one_empty_series() { + let schema = grid_schema(2, 4); + let labels = Labels::from([("job".to_string(), "x".to_string())]); + let empty = || { + let time = + TimeAxis::from_grid_spans(schema.eval_grid.clone().unwrap(), Vec::new()).unwrap(); + let fields = SeriesFields::try_from_vec(vec![ + Samples::from_float(Float64Array::new_null(0)), + Samples::from_float(Float64Array::new_null(0)), + ]) + .unwrap(); + Series::try_new(&schema, metric_of(&labels), time, fields).unwrap() + }; + + let merged = merge_same_labelset(&schema, vec![empty(), empty()]).unwrap(); + assert_eq!(merged.len(), 1, "the label set vanished"); + assert_eq!(merged[0].metric, metric_of(&labels)); + assert_eq!(merged[0].time.len(), 0); + assert!( + merged[0].time.grid().is_some(), + "an empty series keeps its grid" + ); + assert!( + run_ranges(&merged[0]).is_empty(), + "an empty series has no run" + ); + assert_eq!(merged[0].fields.len(), 2); + for field in merged[0].fields.iter() { + assert!(field.is_empty(), "an empty fragment contributed a row"); + } +} + +/// A `Series` built as a struct literal can carry fields that do not line up with its axis; the +/// merge checks the alignment before it reads a row, so it reports such a fragment instead of +/// reading past a field or dropping the values the axis never reaches. +#[test] +fn merge_rejects_fragments_whose_fields_do_not_fill_their_axis() { + let schema = grid_schema(1, 8); + let grid = schema.eval_grid.clone().unwrap(); + let labels = Labels::from([("job".to_string(), "x".to_string())]); + // A well-formed fragment gives the malformed one a label set to merge into. + let partner = || series_of(&schema, &labels, &Points::from([(1, 7f64.to_bits())])).unwrap(); + // `(axis rows, field rows)`: a field shorter than its axis, one longer than it, and a value on + // an axis of no rows at all. The last two leave every axis row self-consistent, so only the + // merge's alignment check catches them. + for (axis_rows, field_rows) in [(2, 1), (1, 2), (0, 1)] { + let spans = if axis_rows == 0 { + Vec::new() + } else { + vec![GridSpan::new(1, axis_rows)] + }; + let time = TimeAxis::from_grid_spans(grid.clone(), spans).unwrap(); + let values = Float64Array::from( + (0..field_rows) + .map(|row| row as f64 + 1.0) + .collect::>(), + ); + let malformed = Series { + metric: metric_of(&labels), + time, + fields: SeriesFields::one(Samples::from_float(values)), + }; + let error = merge_same_labelset(&schema, vec![partner(), malformed]) + .expect_err("a fragment whose fields do not fill its axis merged"); + assert!( + error.to_string().contains("rows of its axis"), + "{axis_rows} axis rows with a {field_rows}-row field: {error}" + ); + } +} + // --------------------------------------------------------------------------------------------- // Acceptance: the builder's observable shape. // diff --git a/src/promql/src/histogram_eval/float_histogram.rs b/src/promql/src/histogram_eval/float_histogram.rs index 71f9598bd81..acce48c1776 100644 --- a/src/promql/src/histogram_eval/float_histogram.rs +++ b/src/promql/src/histogram_eval/float_histogram.rs @@ -80,6 +80,32 @@ impl FloatHistogram { }) } + /// Estimates the heap held by the bucket lists, custom bounds and preserved span layout. + /// + /// A histogram can own tens of kilobytes of buckets that its struct size does not include. + /// Capacity slack and small header overestimates are acceptable for window reservations. + pub(crate) fn heap_bytes(&self) -> usize { + fn buckets(side: &Buckets) -> usize { + side.capacity() * std::mem::size_of::<(i32, f64)>() + } + let layout = self.layout.as_ref().map_or(0, |sides| { + std::mem::size_of::<[Vec<(i32, i32)>; 2]>().saturating_add( + sides + .iter() + .map(|side| side.capacity() * std::mem::size_of::<(i32, i32)>()) + .fold(0, usize::saturating_add), + ) + }); + buckets(&self.positive) + .saturating_add(buckets(&self.negative)) + .saturating_add( + self.custom_values + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + .saturating_add(layout) + } + /// The spans of each side. fn spans(&self) -> [Vec<(i32, i32)>; 2] { self.layout diff --git a/src/promql/src/instant_eval.rs b/src/promql/src/instant_eval.rs index 956bfe93bd9..7c15d26ed69 100644 --- a/src/promql/src/instant_eval.rs +++ b/src/promql/src/instant_eval.rs @@ -22,13 +22,17 @@ use datatypes::series::samples::{SampleRef, SeriesFields}; #[cfg(test)] mod tests; -pub(crate) const MAX_INSTANT_MANIPULATE_OUTPUT_POINTS: usize = 1_000_000; - /// Selects source rows for an aligned PromQL instant-evaluation range. /// /// Returns original logical row indices and evaluation timestamps from a complete source axis /// of strictly increasing milliseconds. Any provided field's stale marker suppresses selection; /// missing samples are not stale and remain selectable. +/// +/// The two vectors it returns hold one `u64` source row index and one `i64` evaluation timestamp +/// per selected point, so what it allocates is 16 bytes per point of the clipped grid rather than +/// anything proportional to the samples of the axis. It reserves none of that: every caller grows +/// a memory reservation for those two vectors before it calls, which is what bounds an instant +/// selection now that the kernel carries no point limit of its own. pub fn select_instants( time: &RawTimestamps, fields: &SeriesFields, @@ -99,11 +103,6 @@ pub fn select_instants( } else { 0 }; - if estimated_points > MAX_INSTANT_MANIPULATE_OUTPUT_POINTS as i128 { - return Err(DataFusionError::Execution(format!( - "InstantManipulate output points exceed limit: {estimated_points} > {MAX_INSTANT_MANIPULATE_OUTPUT_POINTS}" - ))); - } let mut indices = Vec::with_capacity(estimated_points as usize); let mut evaluation_timestamps = Vec::with_capacity(estimated_points as usize); let mut sample_iters: Vec<_> = fields.iter().map(|field| field.iter()).collect(); diff --git a/src/promql/src/instant_eval/tests.rs b/src/promql/src/instant_eval/tests.rs index c13b5fe21b4..9df63e91f52 100644 --- a/src/promql/src/instant_eval/tests.rs +++ b/src/promql/src/instant_eval/tests.rs @@ -190,7 +190,7 @@ fn stale_sample_in_any_field_suppresses_without_falling_back() { } #[test] -fn offset_extreme_timestamps_and_point_limit_are_checked_without_large_fixture() { +fn offset_extreme_timestamps_are_checked_without_large_fixture() { let fields = SeriesFields::one(Samples::from_float(Float64Array::from(vec![Some(7.0)]))); assert_eq!( select(&[i64::MAX], &fields, i64::MAX..=i64::MAX, 1, 0, 0).unwrap(), @@ -209,18 +209,19 @@ fn offset_extreme_timestamps_and_point_limit_are_checked_without_large_fixture() (vec![0], vec![110]) ); + // A grid of a million and one points used to be refused by a point limit of the kernel's own. + // The kernel decides it now, and the reservation its caller grows before the call is what + // stands in for that limit; the stale marker still suppresses the points row zero would answer, + // so only the one sample after it is selected. let fields = SeriesFields::one(Samples::from_float(Float64Array::from(vec![ Some(f64::from_bits( common_query::prometheus::PROMETHEUS_STALE_NAN_BITS, )), Some(1.0), ]))); - let result = select(&[0, 1_000_000], &fields, 0..=1_000_000, 1, 0, 0); - let error = result.unwrap_err().to_string(); - assert!( - error.contains("output points exceed limit: 1000001 > 1000000"), - "{error}" - ); + let (rows, times) = select(&[0, 1_000_000], &fields, 0..=1_000_000, 1, 0, 0).unwrap(); + assert_eq!(rows, [1]); + assert_eq!(times, [1_000_000]); } #[test] diff --git a/src/promql/src/map.rs b/src/promql/src/map.rs index f980fec1b3c..122003e84bf 100644 --- a/src/promql/src/map.rs +++ b/src/promql/src/map.rs @@ -24,7 +24,10 @@ mod go_math; mod tests; pub(crate) use broadcast::validate_scalar; -pub use broadcast::{ScalarSeriesOperand, map_arithmetic_series, map_compare_series}; +pub use broadcast::{ + ScalarFunction, ScalarSeriesOperand, map_arithmetic_series, map_compare_series, + map_scalar_function, +}; pub use comparison::{Comparison, ComparisonOp, map_compare, scalar_arithmetic, scalar_compare}; use std::f64::consts::PI; @@ -316,7 +319,7 @@ fn go_min(x: f64, y: f64) -> f64 { impl ArithmeticOp { /// Prometheus `vectorElemBinop` for two floats. - pub(crate) fn calculate(self, left: f64, right: f64) -> f64 { + pub fn calculate(self, left: f64, right: f64) -> f64 { match self { Self::Add => left + right, Self::Subtract => left - right, diff --git a/src/promql/src/map/broadcast.rs b/src/promql/src/map/broadcast.rs index 092dd9e588d..a9f46d3a471 100644 --- a/src/promql/src/map/broadcast.rs +++ b/src/promql/src/map/broadcast.rs @@ -74,6 +74,58 @@ pub fn map_compare_series( }) } +/// `max_of(a, b)` and `min_of(a, b)`, which Prometheus evaluates with Go's `math.Max` and +/// `math.Min`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScalarFunction { + MaxOf, + MinOf, +} + +impl ScalarFunction { + /// Go's `math.Max` / `math.Min`: an infinity of the function's own sign wins over NaN, NaN + /// over any other value, and `+0` is greater than `-0`. + pub fn apply(self, a: f64, b: f64) -> f64 { + let (wins, signed_zero_wins) = match self { + Self::MaxOf => (f64::INFINITY, false), + Self::MinOf => (f64::NEG_INFINITY, true), + }; + if a == wins || b == wins { + wins + } else if a.is_nan() || b.is_nan() { + f64::NAN + } else if a == 0.0 && a == b { + if a.is_sign_negative() == signed_zero_wins { + a + } else { + b + } + } else if (a > b) == (self == Self::MaxOf) { + a + } else { + b + } + } +} + +/// Applies `function` to two time-varying scalars, aligned by absolute grid index; a point where +/// either has no value has no result. Both operands must be unlabelled single-field series. +pub fn map_scalar_function( + schema: &Arc, + function: ScalarFunction, + left: &Series, + right: &Series, +) -> Result> { + validate_scalar(schema, left)?; + map_grid_scalar( + schema, + left, + ScalarSeriesOperand::Right(right), + left.metric.clone(), + |left, right, _| Some(function.apply(left, right)), + ) +} + /// Calls `calculate(left, right, vector_sample)` at each index where both operands exist. fn map_grid_scalar( schema: &Arc, diff --git a/src/promql/src/map/max_min_cases.txt b/src/promql/src/map/max_min_cases.txt new file mode 100644 index 00000000000..ecd6c0dc738 --- /dev/null +++ b/src/promql/src/map/max_min_cases.txt @@ -0,0 +1,65 @@ +# Generated by promql-compat cmd/narthex-golden-scalarfn built for amd64: `a b math.Max(a,b) math.Min(a,b)` as hex bits. +0 0 0 0 +0 8000000000000000 0 8000000000000000 +0 3ff0000000000000 3ff0000000000000 0 +0 bff0000000000000 0 bff0000000000000 +0 4004000000000000 4004000000000000 0 +0 7ff0000000000000 7ff0000000000000 0 +0 fff0000000000000 0 fff0000000000000 +0 7ff8000000000001 7ff8000000000001 7ff8000000000001 +8000000000000000 0 0 8000000000000000 +8000000000000000 8000000000000000 8000000000000000 8000000000000000 +8000000000000000 3ff0000000000000 3ff0000000000000 8000000000000000 +8000000000000000 bff0000000000000 8000000000000000 bff0000000000000 +8000000000000000 4004000000000000 4004000000000000 8000000000000000 +8000000000000000 7ff0000000000000 7ff0000000000000 8000000000000000 +8000000000000000 fff0000000000000 8000000000000000 fff0000000000000 +8000000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001 +3ff0000000000000 0 3ff0000000000000 0 +3ff0000000000000 8000000000000000 3ff0000000000000 8000000000000000 +3ff0000000000000 3ff0000000000000 3ff0000000000000 3ff0000000000000 +3ff0000000000000 bff0000000000000 3ff0000000000000 bff0000000000000 +3ff0000000000000 4004000000000000 4004000000000000 3ff0000000000000 +3ff0000000000000 7ff0000000000000 7ff0000000000000 3ff0000000000000 +3ff0000000000000 fff0000000000000 3ff0000000000000 fff0000000000000 +3ff0000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001 +bff0000000000000 0 0 bff0000000000000 +bff0000000000000 8000000000000000 8000000000000000 bff0000000000000 +bff0000000000000 3ff0000000000000 3ff0000000000000 bff0000000000000 +bff0000000000000 bff0000000000000 bff0000000000000 bff0000000000000 +bff0000000000000 4004000000000000 4004000000000000 bff0000000000000 +bff0000000000000 7ff0000000000000 7ff0000000000000 bff0000000000000 +bff0000000000000 fff0000000000000 bff0000000000000 fff0000000000000 +bff0000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001 +4004000000000000 0 4004000000000000 0 +4004000000000000 8000000000000000 4004000000000000 8000000000000000 +4004000000000000 3ff0000000000000 4004000000000000 3ff0000000000000 +4004000000000000 bff0000000000000 4004000000000000 bff0000000000000 +4004000000000000 4004000000000000 4004000000000000 4004000000000000 +4004000000000000 7ff0000000000000 7ff0000000000000 4004000000000000 +4004000000000000 fff0000000000000 4004000000000000 fff0000000000000 +4004000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001 +7ff0000000000000 0 7ff0000000000000 0 +7ff0000000000000 8000000000000000 7ff0000000000000 8000000000000000 +7ff0000000000000 3ff0000000000000 7ff0000000000000 3ff0000000000000 +7ff0000000000000 bff0000000000000 7ff0000000000000 bff0000000000000 +7ff0000000000000 4004000000000000 7ff0000000000000 4004000000000000 +7ff0000000000000 7ff0000000000000 7ff0000000000000 7ff0000000000000 +7ff0000000000000 fff0000000000000 7ff0000000000000 fff0000000000000 +7ff0000000000000 7ff8000000000001 7ff0000000000000 7ff8000000000001 +fff0000000000000 0 0 fff0000000000000 +fff0000000000000 8000000000000000 8000000000000000 fff0000000000000 +fff0000000000000 3ff0000000000000 3ff0000000000000 fff0000000000000 +fff0000000000000 bff0000000000000 bff0000000000000 fff0000000000000 +fff0000000000000 4004000000000000 4004000000000000 fff0000000000000 +fff0000000000000 7ff0000000000000 7ff0000000000000 fff0000000000000 +fff0000000000000 fff0000000000000 fff0000000000000 fff0000000000000 +fff0000000000000 7ff8000000000001 7ff8000000000001 fff0000000000000 +7ff8000000000001 0 7ff8000000000001 7ff8000000000001 +7ff8000000000001 8000000000000000 7ff8000000000001 7ff8000000000001 +7ff8000000000001 3ff0000000000000 7ff8000000000001 7ff8000000000001 +7ff8000000000001 bff0000000000000 7ff8000000000001 7ff8000000000001 +7ff8000000000001 4004000000000000 7ff8000000000001 7ff8000000000001 +7ff8000000000001 7ff0000000000000 7ff0000000000000 7ff8000000000001 +7ff8000000000001 fff0000000000000 7ff8000000000001 fff0000000000000 +7ff8000000000001 7ff8000000000001 7ff8000000000001 7ff8000000000001 diff --git a/src/promql/src/map/tests.rs b/src/promql/src/map/tests.rs index e0eea771af1..1cc936287fd 100644 --- a/src/promql/src/map/tests.rs +++ b/src/promql/src/map/tests.rs @@ -551,3 +551,59 @@ fn math_drops_reserved_metadata_and_empty_labels_with_the_name() { assert_eq!(output.metric.name(), None); assert_eq!(names, ["__other__", "host"]); } + +/// `max_of` / `min_of` against Go's `math.Max` / `math.Min`, whose signed-zero, NaN and infinity +/// rules differ from `f64::max` / `f64::min`. +#[test] +fn max_of_and_min_of_follow_go() { + let bits = |text: &str| f64::from_bits(u64::from_str_radix(text, 16).unwrap()); + let same = |a: f64, b: f64| a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()); + let mut checked = 0; + for line in include_str!("max_min_cases.txt") + .lines() + .filter(|line| !line.starts_with('#')) + { + let values: Vec = line.split_whitespace().map(bits).collect(); + let (a, b) = (values[0], values[1]); + assert!( + same(ScalarFunction::MaxOf.apply(a, b), values[2]), + "max_of({a}, {b})" + ); + assert!( + same(ScalarFunction::MinOf.apply(a, b), values[3]), + "min_of({a}, {b})" + ); + checked += 1; + } + assert_eq!(checked, 64); +} + +/// Time-varying scalars pair by absolute position; a point either one lacks has no result. +#[test] +fn scalar_functions_pair_scalars_by_position() { + let schema = Arc::new(schema(&["scalar"])); + let scalar = |start: usize, values: &[Option]| { + Series::try_new( + &schema, + MetricName::try_new(None, Vec::new()).unwrap(), + TimeAxis::Grid( + GridRange::try_new(schema.eval_grid.clone().unwrap(), start, values.len()).unwrap(), + ), + SeriesFields::try_from_vec(vec![floats(values)]).unwrap(), + ) + .unwrap() + }; + let left = scalar(2, &[Some(1.0), Some(5.0), None, Some(-0.0)]); + let right = scalar(3, &[Some(2.0), Some(7.0), Some(0.0), Some(9.0)]); + let result = map_scalar_function(&schema, ScalarFunction::MaxOf, &left, &right) + .unwrap() + .unwrap(); + assert_eq!( + run_floats(&result, 0), + vec![((3, 1), vec![Some(5.0)]), ((5, 1), vec![Some(0.0)])] + ); + assert!( + run_floats(&result, 0)[1].1[0].unwrap().is_sign_positive(), + "max_of(-0, +0) is +0" + ); +} diff --git a/src/promql/src/map_time.rs b/src/promql/src/map_time.rs index 5101cb7fbfa..c0a2c5b94c3 100644 --- a/src/promql/src/map_time.rs +++ b/src/promql/src/map_time.rs @@ -43,6 +43,37 @@ pub enum DateFunction { Year, } +/// `start()`, `end()`, `range()` and `step()`: constants of a query, which Prometheus folds into +/// number literals before evaluating it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryContextFunction { + Start, + End, + Range, + Step, +} + +impl QueryContextFunction { + /// The value for a query from `start_ms` to `end_ms` every `step_ms`. `step()` is 0 when the + /// start and end are equal, as for an instant query. + pub fn value(self, start_ms: i64, end_ms: i64, step_ms: i64) -> f64 { + match self { + Self::Start => start_ms as f64 / 1000.0, + Self::End => end_ms as f64 / 1000.0, + Self::Range => duration_seconds(end_ms.saturating_sub(start_ms)), + Self::Step if start_ms == end_ms => 0.0, + Self::Step => duration_seconds(step_ms), + } + } +} + +/// Go's `time.Duration.Seconds` of `ms` milliseconds: whole seconds plus the remainder over 1e9, +/// two roundings rather than one division. The nanosecond count saturates as `time.Time.Sub` does. +fn duration_seconds(ms: i64) -> f64 { + let nanos = ms.saturating_mul(1_000_000); + (nanos / 1_000_000_000) as f64 + (nanos % 1_000_000_000) as f64 / 1e9 +} + /// `time()`: the evaluation time in seconds at every point of the schema's grid, unlabelled. pub fn time(schema: &SeriesSchema) -> Result { eval_time_series(schema, |ms| ms as f64 / 1000.0) diff --git a/src/promql/src/map_time/duration_seconds_cases.txt b/src/promql/src/map_time/duration_seconds_cases.txt new file mode 100644 index 00000000000..851c93c2196 --- /dev/null +++ b/src/promql/src/map_time/duration_seconds_cases.txt @@ -0,0 +1,211 @@ +# Generated by promql-compat cmd/narthex-golden-scalarfn built for amd64: `ms time.Duration.Seconds()` for a millisecond difference, as hex bits. +0 0 +1 3f50624dd2f1a9fc +999 3feff7ced916872b +1000 3ff0000000000000 +1001 3ff004189374bc6a +4097 40106353f7ced917 +86400000 40f5180000000000 +9007199254740993 42012e0be826d695 +9223372036854 42012e0be826d4fe +9223372036855 42012e0be826d695 +2340891196754 41e170e5479820c5 +28653324801615 42012e0be826d695 +15150070723101 42012e0be826d695 +5203274071043 41f3623a9570b021 +6052846691025 41f68c71c6306666 +19267443321048 42012e0be826d695 +12341472526366 42012e0be826d695 +20530526999188 42012e0be826d695 +34395057096608 42012e0be826d695 +8401369033625 41ff4c2b7c9a0000 +31640814836996 42012e0be826d695 +29341436692007 42012e0be826d695 +34783771064630 42012e0be826d695 +35122158268884 42012e0be826d695 +1479560899851 41d60c13b0f676c9 +23204167308761 42012e0be826d695 +24788341754728 42012e0be826d695 +31429837266203 42012e0be826d695 +34244401504646 42012e0be826d695 +13068139990387 42012e0be826d695 +7761385230625 41fce9d570ea0000 +9735534642756 42012e0be826d695 +20240948685375 42012e0be826d695 +33614386955765 42012e0be826d695 +4944260193162 41f26b36c612978d +14479808172613 42012e0be826d695 +17797368697448 42012e0be826d695 +14261095223522 42012e0be826d695 +13428055133044 42012e0be826d695 +26352584022491 42012e0be826d695 +12325408233432 42012e0be826d695 +17010777739835 42012e0be826d695 +28430422903295 42012e0be826d695 +29753883088875 42012e0be826d695 +25998949474507 42012e0be826d695 +10226187168843 42012e0be826d695 +17769199632283 42012e0be826d695 +4363496320571 41f0415ab80922d1 +3287173645812 41e87dc941b9fbe7 +12725827928836 42012e0be826d695 +24795910633675 42012e0be826d695 +2828521278844 41e512f9e7db020c +4170359431716 41ef125450f6e979 +3589276087583 41eabe0036f2a7f0 +28524985356220 42012e0be826d695 +30320656727859 42012e0be826d695 +4256148642671 41efb5f5945578d5 +29566978976822 42012e0be826d695 +1648135601414 41d88f236c5a7efa +4780677690357 41f1cf35a3a5b646 +3390646811377 41e94325436c1062 +6912080800569 41f9bfdfba091aa0 +19744540834685 42012e0be826d695 +5048451373608 41f2ce9412d9ba5e +31511077099905 42012e0be826d695 +33060246075211 42012e0be826d695 +29446920826861 42012e0be826d695 +20868689814545 42012e0be826d695 +25732868269994 42012e0be826d695 +26023816056118 42012e0be826d695 +3312873794438 41e8aece284e0419 +8928671130029 4200a185acd03b64 +27201078145192 42012e0be826d695 +28161007422010 42012e0be826d695 +23548861302960 42012e0be826d695 +8403898590605 41ff4e950de9ae14 +2101319756957 41df4fe6133d3f7d +18153789976583 42012e0be826d695 +16078846218429 42012e0be826d695 +14439764018086 42012e0be826d695 +15717223852073 42012e0be826d695 +7594418227955 41fc4a9a033f47ae +1007547111184 41ce06f973978d50 +7653748532646 41fc832ef34a5604 +1542530612607 41d6fc498d26d917 +8959567520889 4200b04135071cac +1963454100123 41dd41fba507df3b +15423611227189 42012e0be826d695 +26921592371531 42012e0be826d695 +31560677483706 42012e0be826d695 +21024346859206 42012e0be826d695 +21411506917513 42012e0be826d695 +14299718455620 42012e0be826d695 +25966354044973 42012e0be826d695 +21480240207871 42012e0be826d695 +13383017194856 42012e0be826d695 +19694757267735 42012e0be826d695 +8329763775671 41ff07e1bbfabc6a +24204352256265 42012e0be826d695 +5166480575653 41f33f23cbfa72b0 +17311660727623 42012e0be826d695 +26768379971433 42012e0be826d695 +21452255664239 42012e0be826d695 +32930407231978 42012e0be826d695 +15551692227912 42012e0be826d695 +23123035545994 42012e0be826d695 +5875644708440 41f5e37392470a3d +18292969058316 42012e0be826d695 +1707825217332 41d972d610553f7d +23633727413655 42012e0be826d695 +24472694155897 42012e0be826d695 +32056439689975 42012e0be826d695 +25133237617897 42012e0be826d695 +10984619967605 42012e0be826d695 +12399994332458 42012e0be826d695 +17017307690557 42012e0be826d695 +24405803592380 42012e0be826d695 +34039839184523 42012e0be826d695 +12086900862154 42012e0be826d695 +4880216990563 41f22e2339e9020c +8291016285084 41fee2ede5d15810 +34263874447617 42012e0be826d695 +5219901505275 41f3721604146666 +3325751404254 41e8c75e0d8820c5 +10069265674283 42012e0be826d695 +2205986208125 41e06f95b4040000 +17331608106692 42012e0be826d695 +13164910887275 42012e0be826d695 +20165189440141 42012e0be826d695 +18100958213119 42012e0be826d695 +33252881487365 42012e0be826d695 +28493023658516 42012e0be826d695 +8853845580432 42007dd7b26374bc +34838873892001 42012e0be826d695 +20214329624262 42012e0be826d695 +31705600540751 42012e0be826d695 +31189001046116 42012e0be826d695 +29476027927210 42012e0be826d695 +26781011366842 42012e0be826d695 +22699855557780 42012e0be826d695 +17749079048291 42012e0be826d695 +30750407110207 42012e0be826d695 +24417907650311 42012e0be826d695 +10509573177644 42012e0be826d695 +1079378703273 41d01580c3d178d5 +34862833947828 42012e0be826d695 +24980588397686 42012e0be826d695 +11209991956240 42012e0be826d695 +8937655194575 4200a5ce5cd4999a +23978885618615 42012e0be826d695 +30065528340854 42012e0be826d695 +2628030736846 41e39492221b126f +2663938618407 41e3d90f474d0625 +34895264552379 42012e0be826d695 +15670995231869 42012e0be826d695 +4191047303785 41ef39c9d0f91eb8 +8437263482680 41ff6e66c7aae148 +3960463773594 41ed81fc33b3020c +4309293785322 41f00da9ad9526e9 +26649392599963 42012e0be826d695 +4157860955829 41eefa7d8b7a872b +22296599249089 42012e0be826d695 +32695131508279 42012e0be826d695 +30635397919632 42012e0be826d695 +15331538117365 42012e0be826d695 +11063348740606 42012e0be826d695 +32371941731295 42012e0be826d695 +13719859399284 42012e0be826d695 +7293417131828 41fb2b8b6abd3f7d +33407534042909 42012e0be826d695 +31297857606586 42012e0be826d695 +1097102363540 41d0591d06e28f5c +25332039849587 42012e0be826d695 +20041642732253 42012e0be826d695 +18752081044817 42012e0be826d695 +29442119239956 42012e0be826d695 +28855454932055 42012e0be826d695 +31958604071351 42012e0be826d695 +18688407702704 42012e0be826d695 +17871169582207 42012e0be826d695 +16003600945447 42012e0be826d695 +5284509633417 41f3afb37c16ac08 +11575618223404 42012e0be826d695 +23263846003261 42012e0be826d695 +14260376149051 42012e0be826d695 +20109100243330 42012e0be826d695 +16359587412236 42012e0be826d695 +34836697130263 42012e0be826d695 +23618221322728 42012e0be826d695 +16214535784284 42012e0be826d695 +8272436198602 41fed135be69a1cb +5401941921460 41f41fb17a175c29 +7235498080071 41faf44f060122d1 +14773466706669 42012e0be826d695 +34843449378732 42012e0be826d695 +23707829047074 42012e0be826d695 +4081628494438 41ee6916a9ce0419 +13573087199008 42012e0be826d695 +21542290482225 42012e0be826d695 +16883004474069 42012e0be826d695 +16172951872856 42012e0be826d695 +5849767460850 41f5cac5e24d999a +12771076879764 42012e0be826d695 +7080148168638 41fa6027cc8a353f +13469889734230 42012e0be826d695 +9296342191848 42012e0be826d695 +753418709293 41c67420eaa58106 +23411833150514 42012e0be826d695 +29448477422232 42012e0be826d695 +29413433948272 42012e0be826d695 diff --git a/src/promql/src/map_time/tests.rs b/src/promql/src/map_time/tests.rs index 05128523a65..0124b420129 100644 --- a/src/promql/src/map_time/tests.rs +++ b/src/promql/src/map_time/tests.rs @@ -24,7 +24,7 @@ use datatypes::series::samples::{ }; use datatypes::series::time::{EvalGrid, GridRange, GridSpan, GridSpec, TimeAxis}; -use super::{DateFunction, date_of_eval_time, map_date, time}; +use super::{DateFunction, QueryContextFunction, date_of_eval_time, map_date, time}; use crate::grid_series::merge_same_labelset; use crate::grid_series::reference::{ Labels, Points, canonical_bits, golden_cases, labels_of, metric_of, points_by_labels, @@ -289,3 +289,38 @@ fn map_date_reads_a_sparse_axis_by_its_positions() { ]) ); } + +/// `range()` and `step()` are Go `Duration.Seconds` of a millisecond count, which rounds twice +/// and so differs from `ms / 1000` in the last bit for most values. +#[test] +fn query_context_durations_match_go() { + let mut checked = 0; + for line in include_str!("duration_seconds_cases.txt") + .lines() + .filter(|line| !line.starts_with('#')) + { + let (ms, bits) = line.split_once(' ').unwrap(); + let ms: i64 = ms.parse().unwrap(); + let expected = u64::from_str_radix(bits, 16).unwrap(); + assert_eq!( + QueryContextFunction::Range.value(0, ms, 1).to_bits(), + expected, + "range() over {ms} ms" + ); + assert_eq!( + QueryContextFunction::Step.value(0, 1, ms).to_bits(), + expected, + "step() of {ms} ms" + ); + checked += 1; + } + assert!(checked > 200, "{checked} durations checked"); + // Two valid endpoints whose difference leaves `i64` saturate, as `time.Time.Sub` does. + assert_eq!( + QueryContextFunction::Range.value(i64::MIN / 2, i64::MAX / 2 + 1, 1), + QueryContextFunction::Range.value(0, i64::MAX, 1) + ); + assert_eq!(QueryContextFunction::Step.value(5_000, 5_000, 1_000), 0.0); + assert_eq!(QueryContextFunction::Start.value(-1_500, 0, 1), -1.5); + assert_eq!(QueryContextFunction::End.value(0, 2_250, 1), 2.25); +} diff --git a/src/promql/src/native/batch.rs b/src/promql/src/native/batch.rs index f3982a0115e..0137bbef658 100644 --- a/src/promql/src/native/batch.rs +++ b/src/promql/src/native/batch.rs @@ -23,7 +23,12 @@ //! one, and an inner grid is never addressed with an outer grid's index. //! - A series closes exactly once, on the chunk that carries [`EvaluatedChunk::is_last`]. A closed //! prefix still has to be followed by a final chunk even when that chunk holds no values, so a -//! consumer can tell "nothing more here" from "no more input". +//! consumer can tell "nothing more here" from "no more input". This is per input series, not per +//! label set: a node that rewrites labels can hand two input series over under one label set, and +//! each of them still closes on its own chunk, so a label set may be closed more than once. A +//! series that produced values closes it, and a later input series with no result at all closes it +//! again on the chunk that ends it; a node that can merge two inputs into one series must not read +//! the second close as a defect. //! //! The chunk type itself is [`crate::evaluated::EvaluatedChunk`]: one chunk protocol serves both the diff --git a/src/promql/src/native/execute.rs b/src/promql/src/native/execute.rs new file mode 100644 index 00000000000..25e2c4a3c94 --- /dev/null +++ b/src/promql/src/native/execute.rs @@ -0,0 +1,1937 @@ +// 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. + +//! Running a bound tree, node by node. +//! +//! [`NativeExecutionState`] owns the run-time state one execution of a [`NativeTree`] needs, keyed +//! by the position [`crate::native::metrics::walk`] gives each node. The tree is what the compiler +//! produced and carries no state of its own, so a second execution builds a second state from the +//! same tree and the two never share a buffer, an evaluator or a memory reservation. +//! +//! [`NativeExecutor`] drives that state one chunk at a time. Nodes hand each other +//! [`EvaluatedChunk`]s: a source hands over raw samples, and everything above a window hands over +//! the evaluator's own chunk type. No row batch and no frame is materialised on an internal edge, +//! so a chunk a window produced is fed to the next node as it is. +//! +//! Every window owns a [`RangeEvaluator`], which is what keeps its input grid and its output grid +//! its own: `Window -> Map -> Window` evaluates the inner window on the inner grid and the outer +//! window on the outer grid, and the map in between carries the inner grid through untouched. The +//! outer window reads the inner result as the samples it is, not as something to be resampled onto +//! the outer grid first. +//! +//! A node kind this runtime does not execute yet is rejected when the execution is built, naming +//! the kind and its position. An unsupported tree therefore never starts, rather than answering +//! half a query or panicking halfway through one. +//! +//! A node that rewrites labels — a window drops the metric name and the empty-valued labels, a map +//! drops the name — can turn two distinct input series into one output label set. That pair is +//! legal when the two hold values at disjoint positions, which is exactly what +//! [`crate::grid_series::merge_same_labelset`] merges: `is_last` closes the source series whose +//! data the chunk carries, not the label set it shares with another series, so a second series may +//! fill the positions the first one left empty. The node that rewrote the labels keeps which +//! positions each output label set already occupies; a second series that puts a value where the +//! same field already holds one is refused there — before a node above reads it — while a chunk +//! that continues a set where it ended, or fills positions the set has not occupied, is handed on. +//! +//! A label set reachable by more than one series needs one more guarantee: a window reads one +//! series to its `is_last` before the next one, so the per-series state of its evaluator ends +//! before a second series onto the same set arrives. The window immediately above such a rewriting +//! node therefore collects its whole input, merges each canonical label set once with +//! [`crate::grid_series::merge_same_labelset`], and only then evaluates it: every logical series +//! reaches its evaluator once and whole, and the pieces that would have been separate streams +//! become one. Such a window reports at the end of its input instead of as its input arrives. A +//! window directly over a source, and a map at the root, are not affected and keep streaming their +//! chunks on as they produce them. +//! +//! What leaves the tree is therefore a piece stream, not a series collection: a chunk's `is_last` +//! says the source series it carries ended. [`NativeExecutor::finish`] performs the merge for every +//! affected window inside the tree. A caller that needs one series per canonical label set from the +//! root itself — a downstream window outside this runtime, or a protocol that reports one series — +//! collects the root's chunks and merges them with the same helper. + +use std::collections::hash_map::Entry; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use datafusion::error::{DataFusionError, Result}; +use datafusion::execution::memory_pool::{MemoryConsumer, MemoryPool, MemoryReservation}; +use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; +use datatypes::arrow::array::{Array, Float64Array}; +use datatypes::arrow::buffer::Buffer; +use datatypes::series::histogram::HistogramBatch; +use datatypes::series::raw::RawSeriesChunk; +use datatypes::series::samples::{ + DenseSamplesRef, SampleRef, Samples, Series, SeriesFields, SeriesSchema, +}; +use datatypes::series::time::{GridRange, TimeAxis}; +use datatypes::series::{MetricName, SeriesOrdering}; + +use crate::grid_series::{LabelSet, label_set, merge_same_labelset}; +use crate::map::{MathOp, drop_metric_name, map_math}; +use crate::native::batch::EvaluatedChunk; +use crate::native::metrics::{NodeMetrics, NodePath}; +use crate::native::plan::{NativeExpr, NativeTree}; +use crate::range_eval::RangeEvaluator; + +#[cfg(test)] +mod tests; + +/// A chunk on an internal edge. +/// +/// Only a source hands over raw samples; everything above a window hands over evaluated chunks. +/// The two cannot be confused once the tree is built, because a window's bound input grid says +/// which one its input produces. +#[derive(Clone, Debug)] +enum NativeChunk { + /// Raw source samples, with their own timestamps. + Raw(RawSeriesChunk), + /// Evaluated values, addressed on their absolute grid positions. + Evaluated(EvaluatedChunk), +} + +impl NativeChunk { + /// The positions this chunk carries: decided grid positions, or raw timestamps. + fn positions(&self) -> usize { + match self { + Self::Raw(chunk) => chunk.time.len(), + Self::Evaluated(chunk) => chunk.series.time.len(), + } + } + + /// Whether this chunk closes the source series whose data it carries. + /// + /// A chunk never closes the label set it holds: two source series can share one output label + /// set when the positions they fill are disjoint, and each of them ends on its own chunk. + fn is_last(&self) -> bool { + match self { + Self::Raw(chunk) => chunk.is_last, + Self::Evaluated(chunk) => chunk.is_last, + } + } + + /// An estimate of the bytes this chunk holds. + /// + /// It is what the chunk alone stands for, so a buffer or a histogram table two chunks share is + /// counted once per chunk. That over-count is the price of a figure that costs one pass over + /// the chunk it names and keeps no state between chunks; it is bounded by the number of queued + /// chunks, not by the data they carry together. + fn bytes(&self) -> usize { + match self { + Self::Raw(chunk) => raw_bytes(chunk), + Self::Evaluated(chunk) => evaluated_bytes(chunk), + } + } +} + +/// The bytes a raw chunk holds: its identity, its timestamps and the Arrow buffers behind its +/// fields. +/// +/// The figure is what the chunk keeps alive rather than one float per field and row: a histogram +/// field holds no floats at all, only indices into the table its samples live in. +fn raw_bytes(chunk: &RawSeriesChunk) -> usize { + std::mem::size_of::() + .saturating_add(buffer_bytes(&chunk.primary_key)) + .saturating_add(chunk.metric.as_ref().map_or(0, metric_bytes)) + .saturating_add(buffer_bytes(chunk.time.as_buffer().inner())) + .saturating_add(fields_bytes(&chunk.fields)) +} + +/// The bytes an evaluated chunk holds: its struct and the Arrow buffers behind its fields. +/// +/// The grid is shared by every chunk addressed on it, so it is not counted per chunk. +fn evaluated_bytes(chunk: &EvaluatedChunk) -> usize { + std::mem::size_of::().saturating_add(fields_bytes(&chunk.series.fields)) +} + +/// The bytes one evaluated series holds: its header, the axis that addresses it, the identity it +/// carries and the sample buffers behind its fields. +/// +/// It is the figure a fragment of an affected window is charged with as it arrives. The axis's runs +/// and the segments of its fields are counted in one pass over the series, so the estimate costs +/// the fragment rather than a walk over everything the node holds. A payload a fragment shares with +/// another — a histogram table behind two slices of it, the grid behind every axis — is counted +/// once per fragment: that over-count is accepted here rather than a deduplication that would have +/// to keep state across fragments. +fn series_bytes(series: &Series) -> usize { + let runs = series.time.runs().count(); + let segments: usize = series + .fields + .iter() + .map(|field| field.segments().len()) + .sum(); + std::mem::size_of::() + .saturating_add(std::mem::size_of::()) + .saturating_add(runs.saturating_mul(std::mem::size_of::<(usize, usize)>())) + .saturating_add( + segments.saturating_mul(std::mem::size_of::() + std::mem::size_of::()), + ) + .saturating_add(fields_bytes(&series.fields)) + .saturating_add(metric_bytes(&series.metric)) +} + +/// A rough workspace estimate for merging fragments that hold `rows` rows between them. +/// +/// It allows for sorted row indices, readers, sample descriptors and output headers. Like the +/// retained-state estimates, it is not an allocation bound: vector slack, temporary conversions +/// and null arrays for complementary fields are not measured individually. +/// +/// Existing values and histogram tables remain shared with the fragments, whose estimates already +/// account for their payloads. +fn merge_workspace_bytes(rows: usize, fragments: usize, fields: usize) -> usize { + let sorted_rows = rows.saturating_mul( + std::mem::size_of::<(usize, usize, usize)>() + std::mem::size_of::(), + ); + let readers = fragments.saturating_mul( + fields.saturating_mul(std::mem::size_of::() + 4 * std::mem::size_of::()), + ); + // Allow for run and flattened-field descriptors when rows alternate between fragments or + // missing values; this is an estimate rather than a count of the allocations made. + let merged_field_per_row = + 2 * std::mem::size_of::() + 3 * std::mem::size_of::(); + let merged_fields = rows + .saturating_mul(fields) + .saturating_mul(merged_field_per_row); + let output = fragments.saturating_mul( + std::mem::size_of::() + fields.saturating_mul(std::mem::size_of::()), + ); + sorted_rows + .saturating_add(readers) + .saturating_add(merged_fields) + .saturating_add(output) +} + +/// The bytes the fields of a chunk hold: the sample buffers of every segment, and the histogram +/// tables those segments reference. +fn fields_bytes(fields: &SeriesFields) -> usize { + fields + .iter() + .map(|field| { + field.segments().iter().fold(0usize, |total, segment| { + total.saturating_add(segment_bytes(segment)) + }) + }) + .fold(0usize, usize::saturating_add) +} + +/// The bytes one dense segment of samples holds. +fn segment_bytes(segment: &Samples) -> usize { + match segment.dense() { + Some(DenseSamplesRef::Float(values)) => values.get_array_memory_size(), + Some(DenseSamplesRef::Histogram(histograms)) => histograms + .indices() + .get_array_memory_size() + .saturating_add(table_bytes(histograms.batch())), + Some(DenseSamplesRef::Mixed(mixed)) => mixed + .floats() + .get_array_memory_size() + .saturating_add(mixed.histograms().indices().get_array_memory_size()) + .saturating_add(table_bytes(mixed.histograms().batch())), + // `segments` hands out dense segments only, so a nested chunk never reaches here. + None => 0, + } +} + +/// The bytes one histogram table holds: the batch struct and the arrays its columns view. +fn table_bytes(table: &Arc) -> usize { + std::mem::size_of::().saturating_add(table.as_arrow().get_array_memory_size()) +} + +/// The bytes the allocation `buffer` is a slice of. +/// +/// The capacity is the whole allocation, which is what a slice keeps alive: a key of a few bytes +/// sliced out of a decoded dictionary values buffer pins that buffer. A buffer that owns no memory +/// of its own reports no capacity, and then the bytes it shows are all there is. +fn buffer_bytes(buffer: &Buffer) -> usize { + buffer.capacity().max(buffer.len()) +} + +/// Which node kinds the runtime executes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NodeTag { + /// Forwards the chunks it is given. + Input, + /// Feeds its input to its own [`RangeEvaluator`]. + Window, + /// Applies point-wise operations to its input chunks. + Map, +} + +impl NodeTag { + /// What this tag is called in a rejection. + fn describe(self) -> &'static str { + match self { + Self::Input => "input", + Self::Window => "window", + Self::Map => "map", + } + } +} + +/// One node of the flattened tree. +#[derive(Clone, Copy, Debug)] +struct NodeSpec { + tag: NodeTag, + /// The index of this node's input, which is the node the build pass reaches next. + input: Option, +} + +/// The series whose first chunk this buffer received and whose closing chunk it has not seen. +/// +/// A window reads one series at a time, to the chunk carrying `is_last`, so the chunks of one series +/// arrive together and each of them continues the one before. What is kept here is the identity of +/// the open series and the last position it decided, which is what says whether the next chunk still +/// belongs to it and still moves forward. +struct OpenSeries { + /// The identity of the open series, as the chunk that opened it named it. + metric: MetricName, + /// The last absolute position the open series decided, when it decided any. + last_position: Option, +} + +/// The input a window with a [`NodeRuntime::merge`] collects until its input ends. +/// +/// Such a window cannot evaluate the series under one canonical label set one after another: its +/// evaluator closes a series on the chunk carrying `is_last`, and the points a window across the +/// boundary needs are exactly the ones that closed series held. The fragments are therefore kept +/// whole, merged into one series per label set with +/// [`crate::grid_series::merge_same_labelset`], and only then evaluated. +/// +/// Collecting is also where the contract a series keeps is checked, because the merge that follows +/// would hide a stream that broke it: it sorts the rows by position and groups them by label set, so +/// runs that went backwards inside one series and an identity that changed inside one series both +/// come out as a plausible merged series. A streaming window refuses such a stream on the chunk that +/// breaks it, and so does [`MergeInput::check`] before the chunk is kept. An input that ends inside +/// a series is the one thing left to the executor's own check: it is reported before anything is +/// flushed, because the close a merge synthesises would make such a series look like one that ended. +struct MergeInput { + /// The schema the fragments are addressed on: their grid and their field count. + schema: Arc, + /// The series received so far, in the order they arrived. + fragments: Vec, + /// An estimate of the bytes the fragments and the merge that follows them stand for. + /// + /// A fragment is measured once as it arrives; the merge's workspace is added once, before the + /// helper runs, from the rows, segments and fields of the fragments as they were collected. + /// Neither figure walks the buffer again, and the space the fragments' payload shares is + /// counted once per fragment rather than deduplicated. + bytes: usize, + /// The series the next chunk has to continue, if the last chunk this buffer took did not close + /// one. Nothing is kept once a series closed: the next chunk, if any, starts another series. + open: Option, +} + +impl MergeInput { + /// A buffer for the fragments of one window's input, which are addressed on `schema`. + fn new(schema: Arc) -> Self { + Self { + schema, + fragments: Vec::new(), + bytes: 0, + open: None, + } + } + + /// Checks one received chunk against the contract a series keeps, and follows the open series. + /// + /// The checks are the ones a streaming window makes on every delivery it reads: the chunk is + /// addressed on this window's input grid, carries one sample per row in every field, holds a + /// position unless it closes its series, continues the identity of the series it belongs to, and + /// only ever moves forward within it. They cost a constant number of reads of the chunk's own + /// axis -- its first and last positions, not its rows, which the merge then copies nothing of -- + /// and they are what keeps the sort below honest: a stream that broke one of them is refused + /// here, on the chunk that broke it, rather than merged into an answer. + /// + /// A chunk carrying `is_last` closes the open series, whether it holds rows or none at all, and + /// nothing is remembered of it: the next chunk starts a series whose identity is its own. + fn check(&mut self, path: &NodePath, series: &Series, is_last: bool) -> Result<()> { + let on_grid = match (&self.schema.eval_grid, series.time.grid()) { + (Some(input), Some(grid)) => input.spec() == grid.spec(), + _ => false, + }; + if !on_grid + || series.fields.len() != self.schema.fields.len() + || series + .fields + .iter() + .any(|field| field.len() != series.time.len()) + { + return Err(DataFusionError::Execution(format!( + "the window at {path} was handed an evaluated series that does not match its input \ + schema" + ))); + } + let (first, last) = (series.time.first_position(), series.time.last_position()); + if !is_last && first.is_none() { + return Err(DataFusionError::Execution(format!( + "the window at {path} was handed an evaluated series that holds no position before \ + the last run of its series" + ))); + } + if let Some(open) = &self.open + && open.metric != series.metric + { + return Err(DataFusionError::Execution(format!( + "the window at {path} was handed an evaluated series that changed identity before \ + its last run" + ))); + } + if let Some(previous) = self.open.as_ref().and_then(|open| open.last_position) + && let Some(first) = first + && first <= previous + { + return Err(DataFusionError::Execution(format!( + "the window at {path} was handed evaluated series runs that are not increasing: \ + {first} after {previous}" + ))); + } + self.open = if is_last { + None + } else { + Some(OpenSeries { + metric: series.metric.clone(), + last_position: last, + }) + }; + Ok(()) + } + + /// The bytes of the fragments and of the workspace merging them needs. + /// + /// The workspace is what the helper allocates for the rows the fragments hold; the sample + /// payload is shared with them, so it is not counted twice here. + fn workspace_bytes(&self) -> usize { + let rows: usize = self + .fragments + .iter() + .map(|fragment| fragment.time.len()) + .sum(); + merge_workspace_bytes(rows, self.fragments.len(), self.schema.fields.len()) + } +} + +/// What every node holds: its metric handles and the bytes it reserves. +/// +/// The reservation is the pool's, so a node that buffers input competes with every other operator +/// in the query; the metrics report the same figure so a slow node can be told apart from a wide +/// one. The reservation is also the figure itself: a node holds nothing beside what it reserved, so +/// a gauge reads its size rather than a copy that could drift from it. +struct NodeBuffer { + metrics: Arc, + reservation: MemoryReservation, +} + +impl NodeBuffer { + /// Registers a reservation for the node at `path` with `pool`. + fn new(path: &NodePath, metrics: Arc, pool: &Arc) -> Self { + let consumer = MemoryConsumer::new(format!("native.{}", path.as_str())); + Self { + metrics, + reservation: consumer.register(pool), + } + } + + /// Reserves `bytes` beside what this node already holds. + fn grow(&mut self, bytes: usize) -> Result<()> { + self.reservation.try_grow(bytes)?; + self.record(); + Ok(()) + } + + /// Reserves exactly `bytes`, which is what this node holds from now on. + /// + /// What a node that consumes its input chunk by chunk holds afterwards is not the sum of what + /// it consumed, so the reservation is set from the state that is left rather than grown per + /// chunk. + fn resize(&mut self, bytes: usize) -> Result<()> { + self.reservation.try_resize(bytes)?; + self.record(); + Ok(()) + } + + /// Gives `bytes` of what this node holds back to the pool. + fn shrink(&mut self, bytes: usize) -> Result<()> { + self.reservation.try_shrink(bytes)?; + self.record(); + Ok(()) + } + + /// Reports what this node now reserves. + fn record(&self) { + self.metrics.record_reserved_bytes(self.reservation.size()); + } + + /// Returns every byte this node holds to the pool. + fn release(&mut self) { + self.reservation.free(); + self.record(); + } +} + +/// One run of grid positions, half-open, that hold a value in one field. +#[derive(Clone, Copy, Debug)] +struct Run { + start: usize, + end: usize, +} + +impl Run { + /// Whether two runs share a position. + fn overlaps(self, other: Self) -> bool { + self.start < other.end && other.start < self.end + } +} + +/// The positions one node has handed out, per output label set and field. +/// +/// A node that rewrites labels — a `Map` dropping the metric name, a `Window` dropping the name +/// and the empty-valued labels — can turn two input series into one label set, and the node above +/// reads the two chunks as one series. What is kept here is which positions of each field were +/// already handed out under each label set, so a second series onto a set can be refused before +/// the node above evaluates it. +/// +/// Occupancy is per field and per position, not per chunk range: a chunk reports the positions it +/// decided, which include the ones holding no value, and series that share a decided range while +/// sharing no sample merge cleanly, as they do in [`crate::grid_series::merge_same_labelset`]. A +/// chunk holding no value occupies nothing and changes nothing. +/// +/// A set is never retired: `is_last` closes the source series whose data the chunk carries, and a +/// later series may still fill the positions the closed one left empty. Every run handed out stays +/// charged until the node is released, which is what lets a later chunk be refused against the +/// positions an earlier series already occupied. +/// +/// The table is one node's own: a node knows what it has handed out itself and sees nothing of what +/// the node below kept, so a label set costs its bytes again in every node that keeps a table, and +/// what is deduplicated here is one node's history rather than a plan's. +#[derive(Default)] +struct Emitted { + sets: HashMap, + /// How many run vectors, runs and label payload bytes every entry holds, counted as the entries + /// change rather than by walking them. + /// + /// A node records every label set it has ever handed out and charges for all of them, so the + /// figure is read after every output; walking the table there would cost a pass over every entry + /// recorded so far for each output. Folding one entry's change in as it happens keeps the read + /// constant, and the update is the shape of the change itself: a vector's length before and + /// after [`absorb`], and one vector per field a history gains. + counts: Counts, +} + +/// What the entries of an [`Emitted`] table hold, as running totals. +#[derive(Default)] +struct Counts { + /// The run vectors, one per field of one entry. + vectors: usize, + /// The occupied runs in every vector. + runs: usize, + /// The payload of every label set: the bytes of its names and values with one row per label. + payload: usize, +} + +/// What one node has handed out under one output label set. +#[derive(Default)] +struct History { + /// The occupied runs of each field, in position order and disjoint. + fields: Vec>, +} + +/// The bytes a label set holds: each label's name and value with the pair that refers to them. +fn label_bytes(labels: &LabelSet) -> usize { + labels + .iter() + .map(|(name, value)| std::mem::size_of::<(Arc, Arc)>() + name.len() + value.len()) + .sum() +} + +/// The bytes a metric name holds: its name and each tag's name and value, with the tags that refer +/// to them. +/// +/// This is the convention [`label_bytes`] keeps: the payload of the shared strings and one row per +/// label. +fn metric_bytes(metric: &MetricName) -> usize { + metric.name().map_or(0, |name| name.len()) + + std::mem::size_of_val(metric.tags()) + + metric + .tags() + .iter() + .map(|tag| tag.name.len() + tag.value.len()) + .sum::() +} + +impl Emitted { + /// Records what `chunk` occupies, rejecting it against what this node already handed out. + /// + /// A second series onto a label set is legal when it holds its values where the first one held + /// none: the two merge into one series, which is what + /// [`crate::grid_series::merge_same_labelset`] does. What no merge resolves is two values of one + /// field at one position, so that is the only thing refused here. `is_last` is not consulted: it + /// closes the source series the chunk carries, not the label set, and a later series may still + /// fill the positions that one left empty. + fn occupy(&mut self, path: &NodePath, chunk: &EvaluatedChunk) -> Result<()> { + let labels = label_set(&chunk.series.metric); + let occupied: Vec> = chunk + .series + .fields + .iter() + .enumerate() + .map(|(index, field)| occupied_runs(path, index, field, &chunk.series.time)) + .collect::>()?; + let empty = occupied.iter().all(Vec::is_empty); + if let Some(history) = self.sets.get(&labels) { + for (index, runs) in occupied.iter().enumerate() { + let stored = history.fields.get(index).map_or(&[][..], Vec::as_slice); + for run in runs { + // The stored runs are sorted and disjoint, so only the one holding `run.start` + // or the one just before it can share a position. Scanning them all would make + // a sparse series split across chunks cost a pass over its whole history per + // chunk. + let at = stored.partition_point(|stored| stored.end <= run.start); + let previous = stored.get(at).filter(|stored| stored.overlaps(*run)); + if let Some(previous) = previous { + return Err(occupied_position(path, &labels, index, run, previous)); + } + } + } + } + if empty { + // A chunk holding no value records nothing and closes nothing: a series that answered + // empty leaves every earlier series on this label set exactly where it was. + return Ok(()); + } + let fields = chunk.series.fields.len(); + let history = match self.sets.entry(labels) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + self.counts.payload += label_bytes(entry.key()); + entry.insert(History::default()) + } + }; + if history.fields.len() < fields { + self.counts.vectors += fields - history.fields.len(); + history.fields.resize_with(fields, Vec::new); + } + for (index, runs) in occupied.into_iter().enumerate() { + let stored = &mut history.fields[index]; + let before = stored.len(); + absorb(stored, &runs); + self.counts.runs = self.counts.runs - before + stored.len(); + } + Ok(()) + } + + /// An estimate of the bytes this table holds, which the node reserves for. + /// + /// It counts one label-set slot and one history header per entry, one vector per field of a + /// history, the runs in those vectors, and the payload of every label set. Each of those is a + /// running total, so the figure is read in constant time however many series the node has + /// handed out; it leaves out the map's buckets, the slack of a vector's capacity and the `Arc` + /// headers of the strings it shares, so it is an approximation rather than a measurement. + fn bytes(&self) -> usize { + let slots = + self.sets.len() * (std::mem::size_of::() + std::mem::size_of::()); + slots + + self.counts.vectors * std::mem::size_of::>() + + self.counts.runs * std::mem::size_of::() + + self.counts.payload + } +} + +/// The runs of positions holding a value in field `index`, as the axis `time` places its rows. +/// +/// A missing sample holds no value; a `NaN` and a native histogram do, as they do for a merge. +/// A hole between two rows is not a position either: the axis skips it, so a run open before the +/// hole ends where the hole begins rather than reaching across it. A chunk of several runs is +/// therefore recorded as the runs it holds, and a series that occupies the hole is not refused. +/// +/// The axis is what says how many rows there are, so every field it reads has to hold one sample per +/// row. [`Series`] keeps its fields public, so a chunk built by struct literal can carry fewer, and +/// that is refused here rather than read past the end of the field. +fn occupied_runs( + path: &NodePath, + index: usize, + field: &Samples, + time: &TimeAxis, +) -> Result> { + let mut runs: Vec = Vec::new(); + let mut open: Option = None; + let mut previous: Option = None; + let mut cursor = field.cursor(); + for (position, row) in time.positions() { + // The axis names the absolute position of every row, so the hole between two rows is + // visible as a jump in `position` while no row of this chunk sits in it. + if let Some(previous) = previous + && position != previous + 1 + && let Some(start) = open.take() + { + runs.push(Run { + start, + end: previous + 1, + }); + } + let sample = cursor + .at(row) + .ok_or_else(|| misaligned_field(path, index, time, field))?; + let present = !matches!(sample, SampleRef::Missing); + match (present, open) { + (true, None) => open = Some(position), + (false, Some(start)) => { + runs.push(Run { + start, + end: position, + }); + open = None; + } + _ => {} + } + previous = Some(position); + } + if let Some(start) = open { + // A run still open at the last row ends just past it, not where the field's row count says: + // the rows need not be adjacent to the positions above them. + runs.push(Run { + start, + end: previous.map_or(start, |last| last + 1), + }); + } + Ok(runs) +} + +/// The rejection of a series whose axis and fields disagree on how many rows there are. +/// +/// [`Series`] keeps its fields public, so a chunk built by struct literal — by a caller of this crate +/// as much as by a mistake here — can carry fewer samples than the axis places rows, and then the row +/// the axis names has nothing to read. +fn misaligned_field( + path: &NodePath, + index: usize, + time: &TimeAxis, + field: &Samples, +) -> DataFusionError { + DataFusionError::Execution(format!( + "the node at {path} was handed a series whose axis and fields are misaligned: the axis \ + places {} rows, but field {index} holds {} samples, so the axis names a row the field does \ + not have", + time.len(), + field.len(), + )) +} + +// How many times `absorb` rebuilt the stored runs instead of appending to them. +// +// Test-only, and per thread: the runs stored afterwards are the same either way, so a rebuild shows +// only in what it costs, and a test that reads its own thread's figure is not disturbed by another +// test's chunks. +#[cfg(test)] +thread_local! { + static ABSORB_REBUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread's [`absorb`] rebuilt the stored runs. +#[cfg(test)] +fn absorb_rebuilds() -> usize { + ABSORB_REBUILDS.with(std::cell::Cell::get) +} + +/// Merges `incoming` into `stored`, keeping the result in position order and joining the runs that +/// touch. +/// +/// Within a chunk one pass over both sides is what keeps this linear, since each side is sorted and +/// disjoint. Adding the runs one at a time and rebuilding the array after each would cost the square +/// of the runs a chunk yields: a chunk of one hundred thousand positions alternating between values +/// and nulls yields fifty thousand runs, and re-merging after each is over a billion element moves +/// for a single series. +/// +/// One pass per chunk is still a pass over everything the series has stored so far, so it is not +/// free once the *history* grows across chunks: a series split into `K` chunks of one position each, +/// a hole apart, stores one more run per chunk and moves `K(K+1)/2` of them, with no label collision +/// and no illegal input anywhere. A chunk that starts no earlier than the end of the last stored run +/// only ever adds to the end — the stored runs are sorted and disjoint, so every one of them ends +/// before anything this chunk holds — and it is appended instead, which is amortised constant per +/// run. That is the shape a scan hands over as its positions move forward chunk after chunk. A chunk +/// that starts before the last stored run can split a run or reach back past it, so it still costs +/// the full merge. +/// +/// Either way the result is sorted, disjoint, and has the runs that touch joined, which is what +/// [`Emitted::occupy`] looks a position up by. +fn absorb(stored: &mut Vec, incoming: &[Run]) { + if incoming.is_empty() { + return; + } + if stored + .last() + .is_none_or(|last| incoming[0].start >= last.end) + { + append_runs(stored, incoming); + } else { + merge_runs(stored, incoming); + } +} + +/// Appends `incoming` to `stored`, all of which lies past the last stored run. +/// +/// Nothing already stored has to be placed again: the two sides are each in position order and +/// nothing `incoming` holds can reach back into what is stored, so its runs are pushed on the end and +/// the vector's own growth is the only move — amortised over the runs appended. Where its first run +/// touches the end of the last stored run the two are one run, as they are after a merge, so it is +/// joined rather than left beside it. +fn append_runs(stored: &mut Vec, incoming: &[Run]) { + let mut incoming = incoming; + if let Some(last) = stored.last_mut() + && let Some(first) = incoming.first() + && first.start <= last.end + { + last.end = last.end.max(first.end); + incoming = &incoming[1..]; + } + stored.extend_from_slice(incoming); +} + +/// Merges `incoming` into `stored`, where the two can interleave. +/// +/// One pass is enough because each side is sorted and disjoint: whichever run starts first is +/// placed, and it joins the run before it where the two touch. +fn merge_runs(stored: &mut Vec, incoming: &[Run]) { + #[cfg(test)] + ABSORB_REBUILDS.with(|rebuilds| rebuilds.set(rebuilds.get() + 1)); + let mut merged: Vec = Vec::with_capacity(stored.len() + incoming.len()); + let (mut a, mut b) = (0, 0); + while a < stored.len() || b < incoming.len() { + let larger = match (stored.get(a), incoming.get(b)) { + (Some(left), Some(right)) if right.start < left.start => { + b += 1; + *right + } + (Some(left), _) => { + a += 1; + *left + } + (None, Some(right)) => { + b += 1; + *right + } + (None, None) => break, + }; + match merged.last_mut() { + Some(last) if larger.start <= last.end => last.end = last.end.max(larger.end), + _ => merged.push(larger), + } + } + *stored = merged; +} + +/// What a map node applies. +struct MapRuntime { + ops: Arc<[MathOp]>, + /// The schema of what the map produces, which is what its input is checked against. + schema: Arc, +} + +/// The per-node run-time state of a node. +struct NodeRuntime { + buffer: NodeBuffer, + /// The chunks this node holds for its consumer, each with the estimate it was queued with. + /// + /// An input node's queue is filled by the caller, and a window's by the chunks it computed while + /// flushing its collected input; no node uses it for both. A chunk is measured once, as it + /// enters, and the figure it was queued with is given back as it leaves, so neither the queue + /// nor the handover walks it. + queued: VecDeque<(NativeChunk, usize)>, + /// A window node's evaluator, with its own input and output grid; `None` before it is built and + /// once it is closed. + window: Option, + /// What a map node applies. + map: Option, + /// What this node has already handed out under each output label set, for a node that rewrites + /// labels: a window drops the metric name and the empty-valued labels, a map drops the name. + emitted: Option, + /// The input a window collects until its input ends, for a window whose input can hold two + /// series under one label set; `None` for a window that evaluates its chunks as they arrive, + /// and once the flush that merges the collected input has taken it. + merge: Option, + /// Whether the series the last chunk handed over belongs to is still open. + /// + /// Only an input node ever sets this, because its queue is the one the caller fills; the flag is + /// what reports a truncated input to a tree that has no window, since nothing else in such a + /// tree holds a series and so nothing else could notice one that never ended. It is assigned on + /// every handover -- `false` when that chunk carried `is_last`, `true` when it did not -- and + /// cleared when the node is released, so a true flag says exactly one thing: the last chunk the + /// consumer saw did not close its series, and no chunk came after it. Nothing else writes it, so + /// it cannot report a truncation that did not happen. + /// + /// "The series of the last chunk handed over" is the whole answer because no more than one + /// series can be open here: [`NativeExecutionState::try_new`] refuses an input declaring + /// `SeriesOrdering::Interleaved`, so the chunks of a series arrive together and the series last + /// handed over is the only one that could still be open. + open_source_series: bool, +} + +impl NodeRuntime { + /// Queues a source chunk, holding what it is worth until it leaves. + /// + /// What this node holds is its queue and nothing else, so the reservation is the sum of the + /// estimates of the chunks in it. A chunk is measured once, as it enters, and the figure it was + /// queued with is given back as it leaves, so neither operation walks the queue. + fn enqueue(&mut self, chunk: NativeChunk) -> Result<()> { + let positions = chunk.positions(); + let bytes = chunk.bytes(); + // The chunk is held from here on, so the pool admits it before the queue keeps it: a + // rejected chunk leaves nothing behind. + self.buffer.grow(bytes)?; + self.queued.push_back((chunk, bytes)); + self.buffer.metrics.record_input(1, positions); + Ok(()) + } + + /// Hands over the next queued source chunk, if there is one. + /// + /// The handover is what [`Self::open_source_series`] follows: after this call the flag says + /// whether the series the consumer just received a chunk of is still open, which is what closing + /// the execution reads to tell a truncated input from one that ended cleanly. + fn pop_source(&mut self) -> Result> { + let Some((chunk, bytes)) = self.queued.pop_front() else { + return Ok(None); + }; + let metrics = Arc::clone(&self.buffer.metrics); + { + // An input node's own work is handing the chunk over, so its self time covers that and + // nothing its producer did. The chunk leaves this node, so the pool no longer has to + // hold it. + let _timer = metrics.timer(); + self.buffer.shrink(bytes)?; + } + metrics.record_output(1, chunk.positions()); + // An evaluated source is already addressed on its grid, so its decided positions are grid + // points; a raw source carries timestamps, which are not positions on any grid. + if let NativeChunk::Evaluated(chunk) = &chunk { + metrics.record_grid_points(chunk.series.time.len()); + } + // Assigned rather than only set: a chunk that closes its series is what says no series is + // open any more, so the flag follows the handover and not the history of handovers. + self.open_source_series = !chunk.is_last(); + if chunk.is_last() { + metrics.record_completed_series(1); + } + Ok(Some(chunk)) + } + + /// Maps one evaluated chunk, keeping its axis and its closing. + /// + /// `None` means the operation emptied every position and the chunk does not close its series: + /// there is nothing to hand on, and the input may still decide more. + fn map(&mut self, path: &NodePath, chunk: EvaluatedChunk) -> Result> { + let (positions, closing) = (chunk.series.time.len(), chunk.is_last); + // The chunk is held for as long as this node works on it. + self.buffer.grow(evaluated_bytes(&chunk))?; + let metrics = Arc::clone(&self.buffer.metrics); + let outcome = { + // The timer covers this node's own work only: it is dropped before the driver polls + // the input again. + let _timer = metrics.timer(); + match self.map.as_ref() { + Some(map) => map_chunk(&map.schema, &chunk, &map.ops), + None => Err(DataFusionError::Execution(format!( + "the node at {path} holds no map" + ))), + } + }; + metrics.record_input(1, positions); + if closing { + metrics.record_completed_series(1); + } + // An error leaves the reservation to the driver, which releases every node when it stops. + let output = outcome?; + if let Some(output) = &output { + metrics.record_grid_points(output.series.time.len()); + metrics.record_output(1, valid_values(output)); + // What it handed out is what the label sets of this node now hold: `map_chunk` has + // already rewritten the output's label set, so the label set of the output is the one + // two input series can share. + self.track(path, output)?; + } + // The chunk is gone and the label sets this node has handed out are what is left. + let emitted = self.emitted_bytes(); + self.buffer.resize(emitted)?; + Ok(output) + } + + /// Feeds one chunk to this node's window. + /// + /// A window that collects its input keeps the whole series of every chunk it is handed and + /// evaluates nothing yet: the label sets of those fragments are only canonical once the input + /// has ended, and reading them as separate series would close the state of the one before in + /// the evaluator. Every other window evaluates what it is handed as it arrives, which is what + /// lets it answer before its input series ends. + fn window(&mut self, path: &NodePath, chunk: NativeChunk) -> Result> { + if self.merge.is_some() { + let NativeChunk::Evaluated(chunk) = chunk else { + return Err(DataFusionError::Execution(format!( + "the window at {path} collects evaluated fragments, but it was handed raw \ + samples" + ))); + }; + return self.collect(path, chunk); + } + let (positions, closing) = (chunk.positions(), chunk.is_last()); + // The chunk is held for as long as the evaluator consumes it. + self.buffer.grow(chunk.bytes())?; + let metrics = Arc::clone(&self.buffer.metrics); + let outcome = { + let _timer = metrics.timer(); + match (self.window.as_mut(), &chunk) { + // Raw samples are read window by window by the evaluator itself. + (Some(evaluator), NativeChunk::Raw(chunk)) => evaluator.push(chunk), + // An evaluated input is read as computed samples: the evaluator carries the window + // state of one series across its chunks and closes it on the chunk that says so. + // Handing each chunk over as it arrives is what lets this window report a decision + // before its input series has ended; buffering the chunks into a whole series first + // would hold the input's bytes and delay every result to the end of the series. + (Some(evaluator), NativeChunk::Evaluated(chunk)) => { + evaluator.push_series(&chunk.series, chunk.is_last) + } + (None, _) => Err(closed(path)), + } + }; + metrics.record_input(1, positions); + if closing { + // A series closes exactly once, on the chunk carrying `is_last`. A window that decided + // nothing for it still closed it, so it is counted here rather than on its output. + metrics.record_completed_series(1); + } + // An error leaves the reservation to the driver, which releases every node when it stops. + let output = outcome?; + if let Some(output) = &output { + metrics.record_grid_points(output.series.time.len()); + metrics.record_output(1, valid_values(output)); + self.track(path, output)?; + } + // The chunk has been consumed: what this node still holds is what the evaluator kept of it + // and the label sets it has already handed out. Both are charged whether or not this chunk + // produced one -- a chunk that yields nothing leaves the evaluator's samples and every + // earlier series' history exactly where they were -- and neither is charged per chunk + // consumed, which would make a fixed window over a long series read as an allocation + // failure even though the window holds no more samples. + let retained = self + .window + .as_ref() + .map_or(0, RangeEvaluator::retained_bytes); + let emitted = self.emitted_bytes(); + self.buffer.resize(retained.saturating_add(emitted))?; + Ok(output) + } + + /// Keeps one fragment of this node's collected input, charged for as it arrives. + /// + /// The fragment is measured once, here, and its estimate stands until the flush that merges it + /// is over: a later chunk adds its own estimate rather than walking the buffer again. What is + /// estimated is what the fragment keeps alive, so a sample payload two fragments share is + /// counted once per fragment; the merge itself copies no values, and its workspace is charged + /// separately before it runs. + fn collect( + &mut self, + path: &NodePath, + chunk: EvaluatedChunk, + ) -> Result> { + let Some(merge) = self.merge.as_mut() else { + return Err(DataFusionError::Execution(format!( + "the window at {path} holds no collected input" + ))); + }; + let EvaluatedChunk { series, is_last } = chunk; + // The chunk is checked before it is charged for or kept, so a stream that breaks the + // contract leaves nothing behind. + merge.check(path, &series, is_last)?; + let positions = series.time.len(); + let bytes = series_bytes(&series); + // The fragment is held from here on, so the pool admits it before this node keeps it: a + // rejected fragment leaves nothing behind. + self.buffer.grow(bytes)?; + merge.bytes = merge.bytes.saturating_add(bytes); + merge.fragments.push(series); + let metrics = Arc::clone(&self.buffer.metrics); + metrics.record_input(1, positions); + if is_last { + // The source series whose data this fragment carries ended, which is what `is_last` + // says; the label set it shares with another series is not closed by it. + metrics.record_completed_series(1); + } + // What this node holds is the fragments and the label sets it has already handed out; its + // evaluator has read none of them yet. + let retained = self + .window + .as_ref() + .map_or(0, RangeEvaluator::retained_bytes); + let emitted = self.emitted_bytes(); + let pending = self.merge.as_ref().map_or(0, |merge| merge.bytes); + self.buffer + .resize(retained.saturating_add(emitted).saturating_add(pending))?; + Ok(None) + } + + /// Queues one output this node computed while flushing its collected input. + /// + /// The chunk waits for the pull that hands it up the tree, and its estimate is added as it is + /// queued and given back as it leaves, so the queue costs the sum of what it holds. + fn enqueue_output(&mut self, chunk: EvaluatedChunk) -> Result<()> { + let bytes = evaluated_bytes(&chunk); + self.buffer.grow(bytes)?; + self.queued + .push_back((NativeChunk::Evaluated(chunk), bytes)); + Ok(()) + } + + /// Hands over the next output this node computed while flushing, if there is one. + /// + /// This is not a source handover: [`Self::open_source_series`] and the input metrics follow the + /// chunks the caller queued, and a chunk this node produced itself is neither. + fn take_output(&mut self) -> Result> { + let Some((chunk, bytes)) = self.queued.pop_front() else { + return Ok(None); + }; + self.buffer.shrink(bytes)?; + Ok(Some(chunk)) + } + + /// Records what this node hands out under its output label set. + /// + /// A node that rewrites labels is where two input series become indistinguishable, so this is + /// where a second one is refused: a node above reads what this one hands over, and a window + /// above would evaluate the two as two series and report both. Checking at the root would be + /// too late for that — the answer would already have been computed. + fn track(&mut self, path: &NodePath, output: &EvaluatedChunk) -> Result<()> { + match self.emitted.as_mut() { + Some(emitted) => emitted.occupy(path, output), + // A node that keeps its input's labels cannot make two series one. + None => Ok(()), + } + } + + /// The bytes the label sets this node has handed out hold. + /// + /// Zero for a node that keeps its input's labels, which keeps no such table. + fn emitted_bytes(&self) -> usize { + self.emitted.as_ref().map_or(0, Emitted::bytes) + } + + /// Returns everything this node holds, including queued chunks. + fn release(&mut self) { + // The window goes first: dropping the evaluator is what gives back the memory its + // reservation was taken for. Returning the reservation while the evaluator still held the + // samples would let the pool admit memory the process has not released, for as long as the + // caller keeps the stopped executor alive. The label sets this node handed out and the + // fragments it collected go with it. + self.window = None; + self.emitted = None; + self.merge = None; + // A node that holds no chunk holds no series either, so nothing it handed over is still + // open for it: the flag is what closing the execution reads, and it must not survive the + // state that could have kept a series alive. + self.open_source_series = false; + self.queued.clear(); + self.queued.shrink_to_fit(); + // The reservation is returned once, below, for everything this node still held. + self.buffer.release(); + } +} + +/// The run-time state of one execution of a bound tree. +/// +/// The state is keyed by tree position: the same positions [`crate::native::metrics::walk`] +/// derives, so a node's state, its metrics and the name it is shown under cannot drift apart. +/// Because none of it lives on the tree, one compiled tree serves every execution that builds a +/// state from it. +pub struct NativeExecutionState { + /// The tree's structure, flattened in traversal order, so the root is at index 0. + nodes: Vec, + /// The position of each node, in the same order. + paths: Vec, + /// The per-node state, in the same order. + runtimes: Vec, + /// The index of the tree's input node. + source: usize, + /// Whether the source hands over evaluated chunks rather than raw samples. + source_is_evaluated: bool, +} + +impl NativeExecutionState { + /// Builds the state of one execution of `tree`. + /// + /// Every node registers its metrics in `set` under its position and partition, and every node + /// that buffers input gets its own reservation from `pool`. + pub fn try_new( + tree: &NativeTree, + pool: Arc, + metrics_set: &ExecutionPlanMetricsSet, + partition: usize, + ) -> Result { + // One pass down the tree names and registers every node in the order + // `crate::native::metrics::walk` gives it: the same rule `EXPLAIN` uses, so a node cannot be + // shown at one position and registered at another. Every node this runtime executes is + // unary, so that order is the chain from the root to the input, and a node's input is the + // next one the pass reaches. + let mut nodes: Vec = Vec::new(); + let mut paths: Vec = Vec::new(); + let mut runtimes: Vec = Vec::new(); + // The bound expression of each node, in the same order: the pass below reads the node it + // configures from here, so nothing about an operation is copied onto the node's spec. + let mut exprs: Vec<&NativeExpr> = Vec::new(); + let mut source: Option<(usize, Arc)> = None; + let mut node = tree.root(); + let mut path = NodePath::root(); + loop { + let index = nodes.len(); + let handles = Arc::new(NodeMetrics::default()); + handles.register(&path, metrics_set, partition); + let (tag, child) = match &node.expr { + NativeExpr::Input(bound) => { + // Every node here reads one series at a time and assumes its chunks arrive + // together: a window feeds a series to its evaluator up to the chunk carrying + // `is_last`, and an input node keeps one flag for the series it handed over, + // which is what reports one that never closed. An input that declares + // `Interleaved` can leave a series open across another series' chunks, which + // either of those would misread, so the tree is refused here rather than run. + // That is a precondition of the runtime, not a limitation to document: the tree + // stays free to declare the ordering -- the compiler's job is not narrowed here + // -- and no tree this runtime executes declares it. + if bound.ordering == SeriesOrdering::Interleaved { + return Err(unsupported_ordering(bound.ordering, &path)); + } + let samples = node.output.samples().cloned().ok_or_else(|| { + DataFusionError::Execution(format!( + "the input node at {path} does not produce samples" + )) + })?; + source = Some((index, samples)); + (NodeTag::Input, None) + } + NativeExpr::Window { input, .. } => (NodeTag::Window, Some(input.as_ref())), + NativeExpr::Map { input, .. } => (NodeTag::Map, Some(input.as_ref())), + // The kinds below are legal in a bound tree, and `validate` stays the authority on + // that; this runtime simply does not execute them yet. Rejected before a single + // chunk is read: a tree the runtime cannot execute must not start, because a + // partial answer would be one the caller never asked for. + NativeExpr::Scalar(_) => return Err(not_implemented("Scalar", &path)), + NativeExpr::Instant { .. } => return Err(not_implemented("Instant", &path)), + NativeExpr::Aggregate { .. } => return Err(not_implemented("Aggregate", &path)), + NativeExpr::Binary { .. } => return Err(not_implemented("Binary", &path)), + }; + let buffer = NodeBuffer::new(&path, handles, &pool); + let mut runtime = NodeRuntime { + buffer, + queued: VecDeque::new(), + window: None, + map: None, + emitted: None, + merge: None, + open_source_series: false, + }; + match &node.expr { + NativeExpr::Input(_) => {} + NativeExpr::Map { op, .. } => { + let schema = node.output.samples().cloned().ok_or_else(|| { + DataFusionError::Execution(format!( + "the map node at {path} does not produce samples" + )) + })?; + runtime.map = Some(MapRuntime { + ops: Arc::clone(&op.ops), + schema, + }); + // A map drops the metric name, so it is one of the nodes that can turn two + // series into one label set. + runtime.emitted = Some(Emitted::default()); + } + NativeExpr::Window { input, op } => { + let input_schema = input.output.samples().ok_or_else(|| { + DataFusionError::Execution(format!( + "the window at {path} consumes samples, but its input produces none" + )) + })?; + // One evaluator per window, with the grids the node was bound to: the output + // grid is the node's own grid, which is also the grid its chunks are addressed + // on. + runtime.window = Some(RangeEvaluator::try_new( + input_schema, + Arc::clone(&op.output_grid), + op.window, + op.function, + )?); + // A window drops the metric name and the empty-valued labels, so a `rate` over + // a series whose tag is empty and one where it is absent produces one label set + // from two series. + runtime.emitted = Some(Emitted::default()); + } + NativeExpr::Scalar(_) + | NativeExpr::Instant { .. } + | NativeExpr::Aggregate { .. } + | NativeExpr::Binary { .. } => { + // Rejected above, where the pass has the position to name. + } + } + // The input is the node the pass reaches next: the chain has no other node between + // this one and its input, and nothing is read off the positions afterwards. + nodes.push(NodeSpec { + tag, + input: child.map(|_| index + 1), + }); + exprs.push(&node.expr); + paths.push(path.clone()); + runtimes.push(runtime); + let Some(child) = child else { + break; + }; + node = child; + path = path.input(); + } + + // One reverse pass sets where a merge has to happen, from the input upwards: the pass above + // walked the root first, so a node's input is the node after it and has been decided by the + // time the node itself is. + // + // A source is a stream of series, each with its own label set, so its output needs no merge: + // that is what keeps `Input -> Window` streaming and reporting as its input arrives. A map + // drops the metric name, so its output can hold two input series under one label set, and + // every node above it needs the merge. A window collects its input and merges it when its + // input can hold two series under one label set: reading them as series one after another + // would end the first in the evaluator before the second arrives, and a window across the + // boundary needs both. + // + // A window's own output needs the same when it rewrites labels -- `rate` drops the name, so + // two input series can become one label set -- or when it did not merge its input: + // `last_over_time` keeps the name, but a tag holding an empty value and an absent tag are + // two input series and one canonical label set, so the pair only becomes one series if it + // was merged before the window read it. + // + // The flags this needs are the pass's own: one per node, filled from the input upwards, and + // they and the expressions go when the pass ends. A node's spec therefore stays what the + // tree said, and nothing about an operation is kept once the state is built. + { + let mut holds_shared: Vec = vec![false; nodes.len()]; + for (index, expr) in exprs.into_iter().enumerate().rev() { + let input_holds_shared = + nodes[index].input.is_some_and(|child| holds_shared[child]); + match nodes[index].tag { + NodeTag::Input => {} + NodeTag::Map => holds_shared[index] = true, + NodeTag::Window => { + let (drops_metric_name, input_schema) = match expr { + NativeExpr::Window { input, op } => ( + op.function.drops_metric_name(), + input.output.samples().cloned(), + ), + _ => { + return Err(DataFusionError::Execution(format!( + "the node at {} is a window without a bound window operation", + paths[index] + ))); + } + }; + if input_holds_shared { + let schema = input_schema.ok_or_else(|| { + DataFusionError::Execution(format!( + "the window at {} merges its input, but its input produces no \ + samples", + paths[index] + )) + })?; + runtimes[index].merge = Some(MergeInput::new(schema)); + } + // A window's own output can hold two series under one label set when it drops + // the name, or when it did not merge its input and so never joined the two. + holds_shared[index] = drops_metric_name || !input_holds_shared; + } + } + } + } + + // The runtime's output is an evaluated chunk. Raw samples have no grid, and a scalar or + // aggregate state is not a query value, so neither can be the tree's answer. + match tree.root().output.samples() { + Some(schema) if schema.eval_grid.is_some() => {} + Some(_) => { + return Err(DataFusionError::Execution( + "the native runtime produces evaluated chunks, but the root of the tree \ + produces raw samples" + .to_string(), + )); + } + None => { + return Err(DataFusionError::Execution( + "the native runtime produces evaluated chunks, but the root of the tree \ + produces a scalar or aggregate state" + .to_string(), + )); + } + } + + // The supported nodes are unary, so a tree of them has exactly one input node; it is where + // the caller's chunks enter. + let (source, source_schema) = source.ok_or_else(|| { + DataFusionError::Execution("the native tree has no input node".to_string()) + })?; + Ok(Self { + nodes, + paths, + runtimes, + source, + source_is_evaluated: source_schema.eval_grid.is_some(), + }) + } + + /// The metrics of the node at `path`. + pub fn metrics(&self, path: &NodePath) -> Option<&Arc> { + let index = self.paths.iter().position(|candidate| candidate == path)?; + Some(&self.runtimes[index].buffer.metrics) + } + + /// The bytes this execution currently reserves from the pool. + pub fn reserved_bytes(&self) -> usize { + self.runtimes + .iter() + .map(|runtime| runtime.buffer.reservation.size()) + .sum() + } + + /// How many nodes still hold the state a window buffers into. + /// + /// Test-only: releasing a stopped execution has to drop the evaluators and their sample + /// buffers, not only the numbers that say how many bytes they were reserved for, and that is + /// not observable from the pool. + #[cfg(test)] + pub(crate) fn nodes_holding_windows(&self) -> usize { + self.runtimes + .iter() + .filter(|runtime| runtime.window.is_some()) + .count() + } + + /// The input that ended before its last chunk, if there is one. + /// + /// It is read before anything is merged: a window that collects its input closes every series + /// it merges with a synthetic `is_last`, so a series the caller never ended would be + /// indistinguishable from one that ended afterwards. + fn truncated(&self) -> Option { + self.paths + .iter() + .zip(self.runtimes.iter()) + .find(|(_, runtime)| runtime.open_source_series) + .map(|(path, _)| truncated_input(path)) + } + + /// Closes every node, reporting an input that ended mid-series. + /// + /// A drain finds nothing left to emit: a window reports its results on the chunk that closes + /// the series, so what is left to do is to check that every series did close, and to return + /// everything the execution reserved. A window's evaluator is what holds a series and so + /// notices one that never ended; a tree with no window has none, so there the input node answers + /// the same question from [`NodeRuntime::open_source_series`], which follows what it handed over. + /// The first error found is the one reported, and the nodes are walked from the root down, so a + /// window above the input reports its own truncation as it always did. + fn close(&mut self) -> Result<()> { + let mut result = Ok(()); + for (path, runtime) in self.paths.iter().zip(self.runtimes.iter_mut()) { + if let Some(window) = runtime.window.take() + && let Err(error) = window.finish() + && result.is_ok() + { + result = Err(error); + } + if runtime.open_source_series && result.is_ok() { + result = Err(truncated_input(path)); + } + runtime.release(); + } + result + } +} + +/// Drives one execution of a bound tree. +/// +/// The driver owns the state and nothing else: every decision a node makes was bound before the +/// tree existed, so a pull only reads the position and runs the bound operation. +/// +/// A window directly over a source evaluates its chunks as they arrive, and a map at the root hands +/// its chunks on as it produces them: both stream. A window whose input can hold two series under +/// one canonical label set collects that input instead, merges each set once and evaluates it, so +/// it reports at [`Self::finish`] rather than as its input arrives. +pub struct NativeExecutor { + state: NativeExecutionState, + phase: Phase, +} + +/// Whether the execution can still produce chunks. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Phase { + /// Chunks may still be queued and pulled. + Running, + /// The input ended and every node closed. + Finished, + /// A chunk failed, so the tree stopped: the evaluators must not be reused after an error. + Failed(String), +} + +impl NativeExecutor { + /// Drives `state`, which was built from one execution's tree. + pub fn new(state: NativeExecutionState) -> Self { + Self { + state, + phase: Phase::Running, + } + } + + /// The state this driver runs. + pub fn state(&self) -> &NativeExecutionState { + &self.state + } + + /// Queues one raw source chunk. + pub fn push_raw(&mut self, chunk: RawSeriesChunk) -> Result<()> { + self.push_source(NativeChunk::Raw(chunk)) + } + + /// Queues one evaluated source chunk. + pub fn push_evaluated(&mut self, chunk: EvaluatedChunk) -> Result<()> { + self.push_source(NativeChunk::Evaluated(chunk)) + } + + /// The next chunk the root produces, or `None` when nothing has settled yet. + /// + /// Every node takes whole chunks from its input, so a caller that keeps pushing and pulling + /// materialises nothing beyond the chunk in hand -- except below a window that collects its + /// input, which holds that input until [`Self::finish`]. + /// + /// A chunk's `is_last` says the series whose data it carries ended. It does not say its label + /// set is finished: two source series whose labels were rewritten into one set are two pieces + /// of one logical series, and the merge that makes them one is + /// [`crate::grid_series::merge_same_labelset`]'s. Every window that collects its input performs + /// that merge itself, so a root below such a window hands over one series per label set; a map + /// at the root hands over pieces, and a caller that needs one series per label set merges them. + pub fn next_chunk(&mut self) -> Result> { + self.check_running()?; + // The root is the node the state was built from, which the pass puts first. + let result = self.pull(0).and_then(|chunk| match chunk { + Some(NativeChunk::Evaluated(chunk)) => Ok(Some(chunk)), + // Rejected when the state was built; kept as an error rather than a panic so an + // impossible tree cannot take the process down. + Some(NativeChunk::Raw(_)) => Err(DataFusionError::Execution( + "the native runtime produces evaluated chunks, but its root produced raw samples" + .to_string(), + )), + None => Ok(None), + }); + if let Err(error) = &result { + self.stop(error); + } + result + } + + /// Ends the input: drains what has settled, merges what the windows that collect their input + /// hold, closes every node and releases every reservation. + /// + /// Every series must have closed by now, in every tree: a window's evaluator reports the series + /// it is still holding, and a tree with no window has its input node report one it handed over + /// without the chunk that ends it. An input that ends mid-series is an error here rather than a + /// series the caller never sees close, and it is refused before any collected input is merged: + /// merging closes what it merges. + /// + /// The merge comes next, deepest window first, and what it hands up is drained as it is + /// produced. What the root hands over is therefore a piece stream: a chunk's `is_last` says the + /// source series it carries ended, and two of them can share one label set. A caller that needs + /// one series per label set from the root merges them with + /// [`crate::grid_series::merge_same_labelset`] as well, exactly as this runtime merges the input + /// of the windows it collects for. + pub fn finish(&mut self) -> Result> { + let mut drained = Vec::new(); + loop { + match self.next_chunk() { + Ok(Some(chunk)) => drained.push(chunk), + Ok(None) => break, + Err(error) => return Err(error), + } + } + // A source series that never ended is refused before anything is merged: the close a merge + // synthesises would make it look like one that did. + if let Some(error) = self.state.truncated() { + self.stop(&error); + return Err(error); + } + if let Err(error) = self.flush_merges(&mut drained) { + self.stop(&error); + return Err(error); + } + match self.state.close() { + Ok(()) => { + self.phase = Phase::Finished; + Ok(drained) + } + Err(error) => { + self.stop(&error); + Err(error) + } + } + } + + /// Merges the collected input of every window that collects it, and drains the root after each + /// merged series. + /// + /// Deeper windows go first: a window above one of them collects the chunks the one below hands + /// up, so flushing the shallow one first would merge an input it has not received yet. The + /// queue of a flushed node is emptied by the root drain that follows each of its merged series, + /// so a chunk it produced never stands beside the fragments it was computed from. + fn flush_merges(&mut self, drained: &mut Vec) -> Result<()> { + for index in (0..self.state.runtimes.len()).rev() { + // A window that collects its input is the one node holding a [`MergeInput`], and the + // flush takes it: the same node is not visited twice. + if self.state.runtimes[index].merge.is_none() { + continue; + } + self.flush_merge(index, drained)?; + } + Ok(()) + } + + /// Merges the collected input of the node at `index` and evaluates it, one merged series at a + /// time. + /// + /// Each merged series is a whole series: it is handed to the evaluator as one closing series, so + /// the window reports on it once, and its chunk is queued and drained up the tree before the + /// next one is evaluated. That is what keeps the reservation honest: the fragments and the + /// merge's workspace stay charged while the flush runs, and the queue of a node never holds an + /// earlier series beside the fragments of a later one. + fn flush_merge(&mut self, index: usize, drained: &mut Vec) -> Result<()> { + let path = self.state.paths[index].clone(); + let Some(merge) = self.state.runtimes[index].merge.take() else { + return Ok(()); + }; + if merge.fragments.is_empty() { + // Nothing arrived, so there is nothing to merge and nothing to answer: the window's + // evaluator never read a series and the charge goes back. + return self.settle_merge(index); + } + // The workspace the merge allocates is charged before it runs, from the rows, segments and + // fields of the fragments as they were collected. + let workspace = merge.workspace_bytes(); + let held = merge.bytes.saturating_add(workspace); + let schema = Arc::clone(&merge.schema); + self.state.runtimes[index].buffer.grow(workspace)?; + // The fragments' charge stands for the merged series too -- a merged run is a slice of a + // fragment's own segment, so no value is copied -- and the workspace stays charged until + // the flush is over. + let merged = merge_same_labelset(&schema, merge.fragments)?; + for series in merged { + let output = { + let runtime = &mut self.state.runtimes[index]; + let Some(evaluator) = runtime.window.as_mut() else { + return Err(closed(&path)); + }; + let metrics = Arc::clone(&runtime.buffer.metrics); + let _timer = metrics.timer(); + evaluator.push_series(&series, true)? + }; + if let Some(output) = output { + let runtime = &mut self.state.runtimes[index]; + runtime + .buffer + .metrics + .record_grid_points(output.series.time.len()); + runtime + .buffer + .metrics + .record_output(1, valid_values(&output)); + runtime.track(&path, &output)?; + let retained = runtime + .window + .as_ref() + .map_or(0, RangeEvaluator::retained_bytes); + let emitted = runtime.emitted_bytes(); + runtime + .buffer + .resize(retained.saturating_add(emitted).saturating_add(held))?; + runtime.enqueue_output(output)?; + } + self.drain_root(drained)?; + } + self.settle_merge(index) + } + + /// Sets the reservation of the node at `index` to what it holds once its flush is over. + fn settle_merge(&mut self, index: usize) -> Result<()> { + let runtime = &mut self.state.runtimes[index]; + let retained = runtime + .window + .as_ref() + .map_or(0, RangeEvaluator::retained_bytes); + let emitted = runtime.emitted_bytes(); + runtime.buffer.resize(retained.saturating_add(emitted)) + } + + /// Hands the root's outputs to `drained` until the tree has nothing settled. + fn drain_root(&mut self, drained: &mut Vec) -> Result<()> { + loop { + match self.pull(0)? { + Some(NativeChunk::Evaluated(chunk)) => drained.push(chunk), + Some(NativeChunk::Raw(_)) => { + return Err(DataFusionError::Execution( + "the native runtime produces evaluated chunks, but its root produced raw \ + samples" + .to_string(), + )); + } + None => return Ok(()), + } + } + } + + /// The next chunk of the node at `index`. + fn pull(&mut self, index: usize) -> Result> { + let tag = self.state.nodes[index].tag; + let input = self.state.nodes[index].input; + match tag { + NodeTag::Input => self.state.runtimes[index].pop_source(), + NodeTag::Map => { + let child = input.ok_or_else(|| missing_input(tag))?; + // An operation that empties every position yields no chunk, and the map asks its + // input again rather than reporting a decision it did not make. + loop { + let Some(chunk) = self.pull(child)? else { + return Ok(None); + }; + if let Some(output) = self.map_node(index, chunk)? { + return Ok(Some(output)); + } + } + } + NodeTag::Window => { + // A chunk this window computed while flushing its collected input leaves first: it + // was produced before the window was drained, and the pull that hands it up is the + // one that empties the queue. + if let Some(chunk) = self.state.runtimes[index].take_output()? { + return Ok(Some(chunk)); + } + let child = input.ok_or_else(|| missing_input(tag))?; + // A window reports on the chunk that closes a series, so a chunk that decides + // nothing yields nothing and the window asks its input again. + loop { + let Some(chunk) = self.pull(child)? else { + return Ok(None); + }; + if let Some(output) = self.window_node(index, chunk)? { + return Ok(Some(NativeChunk::Evaluated(output))); + } + } + } + } + } + + /// Maps one chunk the node at `index` pulled from its input. + fn map_node(&mut self, index: usize, chunk: NativeChunk) -> Result> { + let NativeChunk::Evaluated(chunk) = chunk else { + return Err(DataFusionError::Execution(format!( + "the map node at {} consumes an evaluated chunk, not raw samples", + self.state.paths[index] + ))); + }; + let path = &self.state.paths[index]; + let runtime = &mut self.state.runtimes[index]; + Ok(runtime.map(path, chunk)?.map(NativeChunk::Evaluated)) + } + + /// Feeds one chunk to the window at `index`. + fn window_node(&mut self, index: usize, chunk: NativeChunk) -> Result> { + let path = &self.state.paths[index]; + let runtime = &mut self.state.runtimes[index]; + runtime.window(path, chunk) + } + + /// Queues a source chunk at the tree's input node. + fn push_source(&mut self, chunk: NativeChunk) -> Result<()> { + self.check_running()?; + let evaluated = matches!(chunk, NativeChunk::Evaluated(_)); + if evaluated != self.state.source_is_evaluated { + let error = DataFusionError::Execution(format!( + "the native source is bound to {}, but an {} chunk was queued at it", + if self.state.source_is_evaluated { + "an evaluated series" + } else { + "raw samples" + }, + if evaluated { "evaluated" } else { "raw" }, + )); + self.stop(&error); + return Err(error); + } + let source = self.state.source; + let result = self.state.runtimes[source].enqueue(chunk); + if let Err(error) = &result { + self.stop(error); + } + result + } + + /// Refuses to continue an execution that finished or stopped. + fn check_running(&self) -> Result<()> { + match &self.phase { + Phase::Running => Ok(()), + Phase::Finished => Err(DataFusionError::Execution( + "the native execution already finished".to_string(), + )), + Phase::Failed(reason) => Err(DataFusionError::Execution(format!( + "the native execution stopped after an earlier error: {reason}" + ))), + } + } + + /// Stops the execution, returning every byte it reserved to the pool. + /// + /// A stopped execution is not resumed: an evaluator that failed must not be reused, so the + /// chunks it already produced are the only ones the caller will see. + fn stop(&mut self, error: &DataFusionError) { + for runtime in &mut self.state.runtimes { + runtime.release(); + } + self.phase = Phase::Failed(error.to_string()); + } +} + +/// Maps one chunk with `ops`, keeping its axis and its closing. +/// +/// Mapping is point-wise over valued positions, so a chunk whose values survive keeps exactly the +/// rows it came with: a position no field fills is not a row of any chunk, and mapping does not +/// invent one. `None` means the operation emptied every position, which yields no chunk at all +/// unless this chunk closed its series: a closing chunk is still handed over, holding no position, +/// because a consumer tells "nothing more here" from "no more input" by that chunk alone. +fn map_chunk( + schema: &SeriesSchema, + chunk: &EvaluatedChunk, + ops: &[MathOp], +) -> Result> { + let mut series = chunk.series.clone(); + for &op in ops { + series = match map_math(schema, &series, op)? { + Some(mapped) => mapped, + // `clamp` with `max < min` is the empty vector: every position it held was decided and + // holds no value now, so there is no row to hand over. + None => { + return if chunk.is_last { + let empty = no_row_series(schema, &chunk.series)?; + EvaluatedChunk::try_new(empty, true).map(Some) + } else { + Ok(None) + }; + } + }; + } + EvaluatedChunk::try_new(series, chunk.is_last).map(Some) +} + +/// A series carrying the identity of `series` and no row at all. +/// +/// It is what a closing chunk holds when the operation emptied its input: the labels say which +/// series closed, and the axis is empty because no position holds a value. +fn no_row_series(schema: &SeriesSchema, series: &Series) -> Result { + let grid = series.time.grid().ok_or_else(|| { + DataFusionError::Execution("a mapped chunk must be addressed on a grid".to_string()) + })?; + let fields = (0..schema.fields.len()) + .map(|_| Samples::from_float(Float64Array::from(Vec::::new()))) + .collect(); + let fields = SeriesFields::try_from_vec(fields).map_err(series_error)?; + let metric = drop_metric_name(&series.metric)?; + let at = series.time.last_position().map_or(0, |last| last + 1); + let time = GridRange::try_new(Arc::clone(grid), at, 0).map_err(series_error)?; + Series::try_new(schema, metric, TimeAxis::Grid(time), fields).map_err(series_error) +} + +/// The valid values a chunk holds: one per valid field sample. +fn valid_values(chunk: &EvaluatedChunk) -> usize { + chunk + .series + .fields + .iter() + .map(|field| { + field + .iter() + .filter(|sample| !matches!(sample, SampleRef::Missing)) + .count() + }) + .sum() +} + +/// The rejection of a node kind this runtime does not execute yet. +/// +/// It names the kind and the node's position: the position is what the metrics and `EXPLAIN` call +/// the same node, so a reader can find it in the tree. +fn not_implemented(kind: &str, path: &NodePath) -> DataFusionError { + DataFusionError::NotImplemented(format!( + "the native runtime does not execute {kind} nodes yet: {kind} at {path}" + )) +} + +/// The error for a window whose evaluator this execution already closed. +fn closed(path: &NodePath) -> DataFusionError { + DataFusionError::Execution(format!("the window at {path} is already closed")) +} + +/// The rejection of an input ordering this runtime cannot execute. +/// +/// It names the input that declares the ordering and the ordering found, so a reader can tell a +/// tree this runtime cannot run from one it cannot run correctly. +fn unsupported_ordering(ordering: SeriesOrdering, input: &NodePath) -> DataFusionError { + DataFusionError::NotImplemented(format!( + "the native runtime does not execute {ordering:?} input yet: the nodes here read one series \ + at a time, and the input at {input} is {ordering:?}, so the chunks of two of them can \ + arrive interleaved" + )) +} + +/// The rejection of a chunk holding a value where the same field already holds one. +/// +/// No merge resolves this one: at `position`, field `field` of the label set `labels` would carry +/// two values, which is what Prometheus rejects for two series whose label sets became equal. +fn occupied_position( + path: &NodePath, + labels: &LabelSet, + field: usize, + run: &Run, + previous: &Run, +) -> DataFusionError { + DataFusionError::Execution(format!( + "the node at {path} produces two series under the label set {labels:?} that both hold a \ + value of field {field} at position {position}: one occupies {previous:?} and another \ + {run:?}, and no merge resolves two values of one field at one position", + position = run.start.max(previous.start), + )) +} + +/// The rejection of an input that ended without the chunk closing its series. +/// +/// It is what a window's evaluator reports for the input it reads, said by the node that handed the +/// chunks over instead: a tree with no window has no evaluator to hold the series, so the input node +/// is the one node left that can tell a series that ended from one that was cut off. +fn truncated_input(path: &NodePath) -> DataFusionError { + DataFusionError::Execution(format!( + "the input at {path} ended before the last chunk of a series: the last chunk it handed over \ + did not say `is_last`" + )) +} + +/// The error for a node the flattened tree lost its input for. +fn missing_input(tag: NodeTag) -> DataFusionError { + DataFusionError::Execution(format!( + "the native tree has no input for a {} node", + tag.describe() + )) +} + +fn series_error(error: datatypes::error::Error) -> DataFusionError { + DataFusionError::External(Box::new(error)) +} diff --git a/src/promql/src/native/execute/tests.rs b/src/promql/src/native/execute/tests.rs new file mode 100644 index 00000000000..829e79b8802 --- /dev/null +++ b/src/promql/src/native/execute/tests.rs @@ -0,0 +1,3188 @@ +// 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. + +//! What the runtime has to get right: each layer's grid, per-node accounting, rejection and +//! release. + +use std::collections::VecDeque; +use std::sync::Arc; + +use datafusion::error::DataFusionError; +use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool}; +use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricValue}; +use datatypes::arrow::array::{Float64Array, UInt32Array}; +use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; +use datatypes::series::histogram::{ + BucketSideView, CounterResetHint, HistogramBatch, HistogramBatchBuilder, HistogramView, +}; +use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; +use datatypes::series::samples::{ + FieldSpec, SampleRef, Samples, Series, SeriesFields, SeriesSchema, +}; +use datatypes::series::time::{EvalGrid, GridRange, GridSpan, GridSpec, TimeAxis}; +use datatypes::series::{Label, MetricName, SeriesOrdering}; + +use super::{ + Emitted, NativeChunk, NativeExecutionState, NativeExecutor, NodeBuffer, NodeRuntime, + absorb_rebuilds, evaluated_bytes, map_chunk, raw_bytes, +}; +use crate::evaluated::EvaluatedChunk; +use crate::grid_series::merge_same_labelset; +use crate::map::MathOp; +use crate::native::metrics::{NodeMetric, NodeMetrics, NodePath}; +use crate::native::plan::{ + AggregateKind, AggregateStage, BoundAggregate, BoundInput, BoundInstant, BoundMap, BoundScalar, + BoundWindow, Grouping, NativeExpr, NativeNode, NativeTree, NativeType, +}; +use crate::range_eval::{RangeEvaluator, RangeFunction, RangeWindow}; +use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels}; + +fn grid(start: i64, step: i64, len: u64) -> Arc { + Arc::new(EvalGrid::try_new(GridSpec::try_new(start, step, len).unwrap()).unwrap()) +} + +fn fields() -> Arc<[FieldSpec]> { + field_count(1) +} + +/// `count` float fields named `v0`, `v1`, ... +fn field_count(count: usize) -> Arc<[FieldSpec]> { + (0..count) + .map(|index| FieldSpec { + name: format!("v{index}").into(), + }) + .collect() +} + +fn raw_schema() -> Arc { + raw_schema_of(fields()) +} + +fn raw_schema_of(fields: Arc<[FieldSpec]>) -> Arc { + Arc::new(SeriesSchema { + fields, + eval_grid: None, + }) +} + +fn evaluated_schema(grid: Arc) -> Arc { + Arc::new(SeriesSchema { + fields: fields(), + eval_grid: Some(grid), + }) +} + +fn evaluated(grid: Arc) -> NativeType { + NativeType::Samples(evaluated_schema(grid)) +} + +fn metric() -> MetricName { + MetricName::try_new(Some("requests".into()), Vec::new()).unwrap() +} + +/// The raw source every supported tree starts from. +fn input() -> NativeNode { + let output = NativeType::Samples(raw_schema()); + NativeNode { + expr: NativeExpr::Input(BoundInput { + output: output.clone(), + ordering: SeriesOrdering::KeyThenTime, + }), + output, + } +} + +/// The same source over `count` float fields, which one input chunk has to carry all of. +fn raw_input_of(count: usize) -> NativeNode { + let output = NativeType::Samples(raw_schema_of(field_count(count))); + NativeNode { + expr: NativeExpr::Input(BoundInput { + output: output.clone(), + ordering: SeriesOrdering::KeyThenTime, + }), + output, + } +} + +/// A source whose chunks are already evaluated, such as the inner result of a subquery. +fn evaluated_input(grid: Arc) -> NativeNode { + evaluated_input_with_ordering(grid, SeriesOrdering::KeyThenTime) +} + +/// A source of evaluated chunks that guarantees `ordering`. +fn evaluated_input_with_ordering(grid: Arc, ordering: SeriesOrdering) -> NativeNode { + let output = evaluated(grid); + NativeNode { + expr: NativeExpr::Input(BoundInput { + output: output.clone(), + ordering, + }), + output, + } +} + +fn window(inner: NativeNode, output_grid: Arc) -> NativeNode { + window_with( + inner, + RangeFunction::LastOverTime, + 300_000, + None, + output_grid, + ) +} + +fn window_with( + inner: NativeNode, + function: RangeFunction, + range_ms: i64, + input_grid: Option>, + output_grid: Arc, +) -> NativeNode { + NativeNode { + expr: NativeExpr::Window { + input: Box::new(inner), + op: BoundWindow { + function, + window: RangeWindow { + range_ms, + offset_ms: 0, + anchor_ms: None, + }, + input_grid, + output_grid: Arc::clone(&output_grid), + }, + }, + output: evaluated(output_grid), + } +} + +/// A 1 ms `last_over_time` window over `count` float fields, whose output carries them all. +/// +/// Every other window here is over `fields`; this one is what a test of per-field retention needs. +fn window_over_fields(count: usize, output_grid: Arc) -> NativeNode { + let fields = field_count(count); + NativeNode { + expr: NativeExpr::Window { + input: Box::new(raw_input_of(count)), + op: BoundWindow { + function: RangeFunction::LastOverTime, + window: RangeWindow { + range_ms: 1, + offset_ms: 0, + anchor_ms: None, + }, + input_grid: None, + output_grid: Arc::clone(&output_grid), + }, + }, + output: NativeType::Samples(Arc::new(SeriesSchema { + fields, + eval_grid: Some(output_grid), + })), + } +} + +fn map(inner: NativeNode, ops: Vec) -> NativeNode { + let output = inner.output.grid().expect("a map needs a grid").clone(); + NativeNode { + expr: NativeExpr::Map { + input: Box::new(inner), + op: BoundMap { ops: ops.into() }, + }, + output: evaluated(output), + } +} + +fn scalar(value: f64, grid: Arc) -> NativeNode { + NativeNode { + expr: NativeExpr::Scalar(BoundScalar { value }), + output: NativeType::Scalar(grid), + } +} + +fn raw_chunk( + schema: &SeriesSchema, + timestamps: &[i64], + values: &[f64], + is_last: bool, +) -> RawSeriesChunk { + raw_chunk_of(schema, b"series-a", metric(), timestamps, values, is_last) +} + +/// One raw chunk of the series named by `key` and `metric`. +fn raw_chunk_of( + schema: &SeriesSchema, + key: &[u8], + metric: MetricName, + timestamps: &[i64], + values: &[f64], + is_last: bool, +) -> RawSeriesChunk { + RawSeriesChunk::try_new( + schema, + Buffer::from_slice_ref(key), + Some(metric), + RawTimestamps::try_new(ScalarBuffer::from(timestamps.to_vec())).unwrap(), + SeriesFields::one(Samples::from_float(Float64Array::from(values.to_vec()))), + is_last, + ) + .unwrap() +} + +/// One raw chunk of `series-a` whose every field carries `values`, so one input can be pushed to a +/// schema of more than one field. +fn raw_chunk_repeated( + schema: &SeriesSchema, + timestamps: &[i64], + values: &[f64], + is_last: bool, +) -> RawSeriesChunk { + let fields = SeriesFields::try_from_vec( + (0..schema.fields.len()) + .map(|_| Samples::from_float(Float64Array::from(values.to_vec()))) + .collect(), + ) + .unwrap(); + RawSeriesChunk::try_new( + schema, + Buffer::from_slice_ref(b"series-a"), + Some(metric()), + RawTimestamps::try_new(ScalarBuffer::from(timestamps.to_vec())).unwrap(), + fields, + is_last, + ) + .unwrap() +} + +/// Pushes one native histogram whose positive side is one span of `buckets` buckets of one count +/// into `builder`, and returns the index it was filed under. +fn push_histogram(builder: &mut HistogramBatchBuilder, buckets: i32) -> u32 { + let offsets = [0]; + let lengths = [buckets]; + let counts = vec![1.0; usize::try_from(buckets).unwrap()]; + builder + .push(HistogramView { + schema: 0, + zero_threshold: 0.0, + zero_count: 0.0, + count: f64::from(buckets), + sum: f64::from(buckets), + reset_hint: CounterResetHint::Unknown, + start_timestamp: None, + custom_values: &[], + positive: BucketSideView { + span_offsets: &offsets, + span_lengths: &lengths, + buckets: &counts, + }, + negative: BucketSideView { + span_offsets: &[], + span_lengths: &[], + buckets: &[], + }, + }) + .unwrap() +} + +/// One raw chunk of `series-a` whose one field holds a `buckets`-wide native histogram at each of +/// `timestamps`. +fn raw_histogram_chunk( + schema: &SeriesSchema, + timestamps: &[i64], + buckets: i32, + is_last: bool, +) -> RawSeriesChunk { + let mut builder = HistogramBatchBuilder::new(); + let indices: UInt32Array = timestamps + .iter() + .map(|_| Some(push_histogram(&mut builder, buckets))) + .collect(); + let samples = Samples::try_from_histogram(indices, builder.finish().unwrap()).unwrap(); + RawSeriesChunk::try_new( + schema, + Buffer::from_slice_ref(b"series-a"), + Some(metric()), + RawTimestamps::try_new(ScalarBuffer::from(timestamps.to_vec())).unwrap(), + SeriesFields::one(samples), + is_last, + ) + .unwrap() +} + +/// One raw chunk of `series-a` whose one field holds the histogram at `index` of `table`. +/// +/// Two chunks built this way share one `Arc`, which is what makes the allocation +/// one table for accounting even though each chunk carries its own indices into it. +fn raw_shared_histogram_chunk( + schema: &SeriesSchema, + table: &Arc, + index: u32, + timestamp: i64, + is_last: bool, +) -> RawSeriesChunk { + let samples = + Samples::try_from_histogram(UInt32Array::from(vec![Some(index)]), Arc::clone(table)) + .unwrap(); + RawSeriesChunk::try_new( + schema, + Buffer::from_slice_ref(b"series-a"), + Some(metric()), + RawTimestamps::try_new(ScalarBuffer::from(vec![timestamp])).unwrap(), + SeriesFields::one(samples), + is_last, + ) + .unwrap() +} + +/// A source node's runtime: the queue and the reservation it is held for. +fn source_runtime(pool: &Arc) -> NodeRuntime { + NodeRuntime { + buffer: NodeBuffer::new( + &NodePath::root().input(), + Arc::new(NodeMetrics::default()), + pool, + ), + queued: VecDeque::new(), + window: None, + map: None, + emitted: None, + merge: None, + open_source_series: false, + } +} + +/// One evaluated chunk of an input series: `values` at `start` and onwards on `schema`'s grid. +fn evaluated_chunk( + schema: &SeriesSchema, + start: usize, + values: Vec, + is_last: bool, +) -> EvaluatedChunk { + evaluated_chunk_of( + schema, + metric(), + start, + values.into_iter().map(Some).collect(), + is_last, + ) +} + +/// One evaluated chunk of the series `metric`, `values` at `start` and onwards on `schema`'s grid. +/// +/// A `None` value is a position the series decided to hold no sample, which is what an inner +/// evaluated result carries where it had nothing to report. +fn evaluated_chunk_of( + schema: &SeriesSchema, + metric: MetricName, + start: usize, + values: Vec>, + is_last: bool, +) -> EvaluatedChunk { + let samples = Samples::from_float(Float64Array::from(values)); + let range = + GridRange::try_new(schema.eval_grid.clone().unwrap(), start, samples.len()).unwrap(); + let series = Series { + metric, + time: TimeAxis::Grid(range), + fields: SeriesFields::try_from_vec(vec![samples]).unwrap(), + }; + EvaluatedChunk::try_new(series, is_last).unwrap() +} + +/// The series `name{job="job"}`, so two series can share the label a map leaves behind. +fn named_metric(name: &str, job: &str) -> MetricName { + MetricName::try_new( + Some(name.into()), + vec![Label { + name: "job".into(), + value: job.into(), + }], + ) + .unwrap() +} + +/// `requests{host="web-1"}` with `zone` either present and empty or absent. +/// +/// The two are distinct series to a scan — an empty tag value is a present label, an absent one is +/// not — and the same label set to everything that drops empty-valued labels, as Prometheus does. +fn host_metric(zone: Option<&str>) -> MetricName { + let mut tags = vec![Label { + name: "host".into(), + value: "web-1".into(), + }]; + if let Some(zone) = zone { + tags.push(Label { + name: "zone".into(), + value: zone.into(), + }); + } + MetricName::try_new(Some("requests".into()), tags).unwrap() +} + +fn pool() -> Arc { + Arc::new(GreedyMemoryPool::new(1 << 20)) +} + +/// A pool with a fixed budget, so a reservation that grows with the input fails here. +fn bounded_pool(bytes: usize) -> Arc { + Arc::new(GreedyMemoryPool::new(bytes)) +} + +fn executor( + tree: &NativeTree, + pool: Arc, + set: &ExecutionPlanMetricsSet, +) -> NativeExecutor { + NativeExecutor::new(NativeExecutionState::try_new(tree, pool, set, 0).unwrap()) +} + +/// Pulls until the tree has nothing settled, then closes it. +fn run(executor: &mut NativeExecutor) -> Vec { + let mut out = Vec::new(); + while let Some(chunk) = executor.next_chunk().unwrap() { + out.push(chunk); + } + out.extend(executor.finish().unwrap()); + out +} + +fn sample_values(chunk: &EvaluatedChunk) -> Vec> { + let field = chunk + .series + .fields + .get(0) + .expect("these series have one field"); + field + .iter() + .map(|sample| match sample { + SampleRef::Float(value) => Some(value), + _ => None, + }) + .collect() +} + +fn series_start(chunk: &EvaluatedChunk) -> usize { + chunk + .series + .time + .first_position() + // An empty chunk carries no row, but where it sits is still what it addresses: a closing + // chunk that holds nothing is handed over exactly so the consumer learns it closed. + .or_else(|| chunk.series.time.as_grid_range().map(GridRange::start)) + .expect("every chunk in these tests is addressed on a grid") +} + +/// The absolute start and the values of every chunk, which is what addressing means here. +fn addressed(chunks: &[EvaluatedChunk]) -> Vec<(usize, Vec>)> { + chunks + .iter() + .map(|chunk| (series_start(chunk), sample_values(chunk))) + .collect() +} + +/// Every decided position of a chunk stream with its value, in absolute grid order. +/// +/// Two streams that decided the same positions hold the same result however they were split into +/// chunks, which is what "the same series, delivered differently" means. +/// +/// The positions come from the axis, not from the chunk's start plus a row number: a chunk whose +/// rows form several runs carries them at absolute positions that are not adjacent, so adding the +/// row to the start would name positions the chunk does not hold. +fn decided(chunks: &[EvaluatedChunk]) -> Vec<(usize, Option)> { + chunks + .iter() + .flat_map(|chunk| { + let values = sample_values(chunk); + chunk + .series + .time + .positions() + .map(|(position, row)| { + ( + position, + *values.get(row).expect("one value per carried row"), + ) + }) + .collect::>() + }) + .collect() +} + +fn count(set: &ExecutionPlanMetricsSet, path: &NodePath, metric: &str) -> usize { + let name = path.metric_name(metric); + match set.clone_inner().sum_by_name(&name) { + Some(MetricValue::Count { count, .. }) => count.value(), + other => panic!("no count named {name}: {other:?}"), + } +} + +fn gauge(set: &ExecutionPlanMetricsSet, name: &str) -> usize { + match set.clone_inner().sum_by_name(name) { + Some(MetricValue::Gauge { gauge, .. }) => gauge.value(), + other => panic!("no gauge named {name}: {other:?}"), + } +} + +/// A window over raw input evaluates onto its own output grid, and its values are the evaluator's. +/// +/// The grid is compared by allocation identity, so a window that evaluated on a copy of its +/// output grid — the same timestamps, a different grid — fails here even though every value and +/// every timestamp would still look right. +#[test] +fn a_window_over_raw_input_evaluates_onto_its_own_grid() { + let out_grid = grid(0, 60_000, 4); + let tree = NativeTree::try_new(window(input(), Arc::clone(&out_grid))).unwrap(); + let schema = raw_schema(); + let chunk = raw_chunk( + &schema, + &[0, 60_000, 120_000, 180_000, 240_000], + &[1.0, 2.0, 3.0, 4.0, 5.0], + true, + ); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution.push_raw(chunk.clone()).unwrap(); + let out = run(&mut execution); + + // The same samples, evaluated by the evaluator this runtime wraps. + let mut evaluator = RangeEvaluator::try_new( + &schema, + Arc::clone(&out_grid), + RangeWindow { + range_ms: 300_000, + offset_ms: 0, + anchor_ms: None, + }, + RangeFunction::LastOverTime, + ) + .unwrap(); + let expected: Vec<(usize, Vec>)> = evaluator + .push(&chunk) + .unwrap() + .iter() + .map(|produced| (series_start(produced), sample_values(produced))) + .collect(); + evaluator.finish().unwrap(); + + assert_eq!(out.len(), 1, "one closed series, one chunk"); + for chunk in &out { + assert!( + Arc::ptr_eq(chunk.series.time.grid().expect("a grid chunk"), &out_grid), + "the chunk is addressed on a grid that is not the window's output grid" + ); + assert_eq!( + chunk.series.time.grid().expect("a grid chunk").spec(), + out_grid.spec() + ); + assert!(chunk.is_last); + } + assert_eq!( + addressed(&out), + vec![(0, vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)])] + ); + assert_eq!(addressed(&out), expected); +} + +/// `Window -> Map -> Window` keeps the inner grid inside and the outer grid outside. +/// +/// The inner window runs on a 60 s grid and the outer one on a 90 s grid offset by 30 s, so a +/// layer that resampled onto its consumer's grid would change the addressing of every chunk and +/// the values of the last one. +#[test] +fn a_window_map_window_chain_keeps_each_layer_on_its_own_grid() { + let inner_grid = grid(0, 60_000, 5); + let outer_grid = grid(30_000, 90_000, 3); + let inner = || { + window_with( + input(), + RangeFunction::LastOverTime, + 120_000, + None, + Arc::clone(&inner_grid), + ) + }; + let mapped = || map(inner(), vec![MathOp::Abs]); + let chain = || { + window_with( + map(inner(), vec![MathOp::Abs]), + RangeFunction::LastOverTime, + 240_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + ) + }; + let schema = raw_schema(); + let chunk = raw_chunk( + &schema, + &[0, 60_000, 120_000, 180_000, 240_000], + &[-1.0, -2.0, -3.0, -4.0, -5.0], + true, + ); + + // Down to the map, on its own: the map's chunks stay addressed on the inner window's grid. + let map_tree = NativeTree::try_new(mapped()).unwrap(); + let mut map_execution = executor(&map_tree, pool(), &ExecutionPlanMetricsSet::new()); + map_execution.push_raw(chunk.clone()).unwrap(); + let mapped_chunks = run(&mut map_execution); + assert_eq!(mapped_chunks.len(), 1); + for chunk in &mapped_chunks { + assert!( + Arc::ptr_eq(chunk.series.time.grid().expect("a grid chunk"), &inner_grid), + "the map handed over a chunk that is not addressed on the inner grid" + ); + } + assert_eq!(mapped_chunks[0].series.time.len(), 5); + assert_eq!( + addressed(&mapped_chunks), + vec![( + 0, + vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0), Some(5.0)] + )] + ); + + // The whole chain: only the outer window's grid leaves the tree. + let chain_tree = NativeTree::try_new(chain()).unwrap(); + let mut chain_execution = executor(&chain_tree, pool(), &ExecutionPlanMetricsSet::new()); + chain_execution.push_raw(chunk.clone()).unwrap(); + let out = run(&mut chain_execution); + assert_eq!(out.len(), 1); + for chunk in &out { + assert!( + Arc::ptr_eq(chunk.series.time.grid().expect("a grid chunk"), &outer_grid), + "the chain handed over a chunk that is not addressed on the outer grid" + ); + } + assert_eq!(out[0].series.time.len(), 3); + assert_eq!( + addressed(&out), + vec![(0, vec![Some(1.0), Some(3.0), Some(4.0)])] + ); + + // The same chain driven by hand, one kernel at a time. + let mut inner_evaluator = RangeEvaluator::try_new( + &schema, + Arc::clone(&inner_grid), + RangeWindow { + range_ms: 120_000, + offset_ms: 0, + anchor_ms: None, + }, + RangeFunction::LastOverTime, + ) + .unwrap(); + let inner_series = inner_evaluator.push(&chunk).unwrap().unwrap(); + inner_evaluator.finish().unwrap(); + let map_schema = evaluated_schema(Arc::clone(&inner_grid)); + let mapped_chunk = map_chunk(&map_schema, &inner_series, &[MathOp::Abs]) + .unwrap() + .expect("`abs` maps every position, so the chunk keeps its rows"); + assert!( + Arc::ptr_eq( + mapped_chunk.series.time.grid().expect("a grid chunk"), + &inner_grid + ), + "the map kernel moved the inner grid" + ); + let mut outer_evaluator = RangeEvaluator::try_new( + &map_schema, + Arc::clone(&outer_grid), + RangeWindow { + range_ms: 240_000, + offset_ms: 0, + anchor_ms: None, + }, + RangeFunction::LastOverTime, + ) + .unwrap(); + let outer_series = outer_evaluator + .push_series(&mapped_chunk.series, mapped_chunk.is_last) + .unwrap() + .unwrap(); + outer_evaluator.finish().unwrap(); + assert_eq!( + addressed(&out), + vec![(series_start(&outer_series), sample_values(&outer_series))] + ); +} + +/// A window over an evaluated input reports a decision before its input series has ended. +/// +/// The inner series arrives in two pieces and the window is pulled after the first one, so the +/// outer grid point whose window that piece settled has to come out then. A layer that collected +/// the pieces into a whole series first could not produce anything here: its first answer would +/// only exist once the piece carrying `is_last` had arrived, and the bytes of the whole series +/// would be held until then. +#[test] +fn a_downstream_window_emits_before_its_input_series_ends() { + // 60 s inner grid, 120 s outer grid, so the inner series settles outer points as it arrives. + let inner_grid = grid(0, 60_000, 5); + let outer_grid = grid(0, 120_000, 3); + let tree = NativeTree::try_new(window_with( + evaluated_input(Arc::clone(&inner_grid)), + RangeFunction::LastOverTime, + 240_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + + // The first piece covers inner positions 0..3; the series is still open after it. + let head = evaluated_chunk(&schema, 0, vec![1.0, 2.0, 3.0], false); + let tail = evaluated_chunk(&schema, 3, vec![4.0, 5.0], true); + + let set = ExecutionPlanMetricsSet::new(); + let mut execution = executor(&tree, pool(), &set); + execution.push_evaluated(head).unwrap(); + + let mut early = Vec::new(); + while let Some(chunk) = execution.next_chunk().unwrap() { + early.push(chunk); + } + assert!( + !early.is_empty(), + "the outer window buffered its input: nothing came out before the series ended" + ); + // The piece settled outer points 0 and 1: their windows end inside it, at the inner values + // 1.0 and 3.0. + assert_eq!( + decided(&early), + vec![(0, Some(1.0)), (1, Some(3.0))], + "the outer window did not report the points its input had already settled" + ); + for chunk in &early { + assert!( + !chunk.is_last, + "the series cannot close before its last piece" + ); + } + // The open outer series is still accounted for: the pieces handed over are what it holds. + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES), + 0, + "a series closed before its last piece arrived" + ); + + // The closing piece: the rest of the outer grid, and the one chunk that closes the series. + execution.push_evaluated(tail).unwrap(); + let mut out = early; + while let Some(chunk) = execution.next_chunk().unwrap() { + out.push(chunk); + } + out.extend(execution.finish().unwrap()); + assert_eq!( + decided(&out), + vec![(0, Some(1.0)), (1, Some(3.0)), (2, Some(5.0)),] + ); + assert_eq!(out.iter().filter(|chunk| chunk.is_last).count(), 1); + assert!(out.last().unwrap().is_last); + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES), + 1, + "the outer series closed once per piece instead of once in total" + ); + + // The same chain with the whole series in one piece answers exactly the same. + let whole_tree = NativeTree::try_new(window_with( + evaluated_input(Arc::clone(&inner_grid)), + RangeFunction::LastOverTime, + 240_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let mut whole = executor(&whole_tree, pool(), &ExecutionPlanMetricsSet::new()); + whole + .push_evaluated(evaluated_chunk( + &schema, + 0, + vec![1.0, 2.0, 3.0, 4.0, 5.0], + true, + )) + .unwrap(); + let whole = run(&mut whole); + assert_eq!(decided(&out), decided(&whole)); + for chunk in &whole { + assert!( + Arc::ptr_eq(chunk.series.time.grid().expect("a grid chunk"), &outer_grid), + "the chain handed over a chunk that is not addressed on the outer grid" + ); + } +} + +/// Every node kind the runtime does not execute is rejected, naming the kind and its position. +#[test] +fn an_unimplemented_node_is_reported_not_panicked() { + // One grid shared by the nodes that validate by allocation identity. + let statement = || grid(0, 120_000, 2); + + let scalar_tree = scalar(1.0, statement()); + let instant_tree = { + let out = statement(); + map( + NativeNode { + expr: NativeExpr::Instant { + input: Box::new(input()), + op: BoundInstant { + lookback_ms: 300_000, + offset_ms: 0, + output_grid: Arc::clone(&out), + }, + }, + output: NativeType::Samples(evaluated_schema(out)), + }, + vec![MathOp::Abs], + ) + }; + let aggregate_tree = { + let out = statement(); + NativeNode { + expr: NativeExpr::Aggregate { + input: Box::new(window(input(), Arc::clone(&out))), + op: BoundAggregate { + kind: AggregateKind::Sum, + group: Grouping::By(Arc::new(["job".into()])), + stage: AggregateStage::Complete, + }, + }, + output: NativeType::Samples(evaluated_schema(out)), + } + }; + let binary_tree = { + let out = statement(); + NativeNode { + expr: NativeExpr::Binary { + left: Box::new(window(input(), Arc::clone(&out))), + right: Box::new(scalar(1.0, Arc::clone(&out))), + matching: BinaryMatching { + op: BinaryOp::Add, + return_bool: false, + labels: MatchingLabels::Ignoring(Vec::new()), + cardinality: Cardinality::OneToOne, + fill: FillValues::default(), + }, + }, + output: NativeType::Samples(evaluated_schema(out)), + } + }; + + let cases = [ + ("Scalar", "root", scalar_tree), + ("Instant", "root.input", instant_tree), + ("Aggregate", "root", aggregate_tree), + ("Binary", "root", binary_tree), + ]; + for (kind, path, root) in cases { + // Legality stays `validate`'s: the plan accepts these trees, the runtime does not run them. + let tree = NativeTree::try_new(root).expect("the bound tree is legal"); + let error = match NativeExecutionState::try_new( + &tree, + pool(), + &ExecutionPlanMetricsSet::new(), + 0, + ) { + Ok(_) => panic!("the runtime accepted a {kind} node"), + Err(error) => error, + }; + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "{kind}: {error}" + ); + let message = error.to_string(); + assert!(message.contains(kind), "{kind}: {message}"); + assert!(message.contains(&format!("at {path}")), "{message}"); + } +} + +/// Each node reports its own chunks, grid points and closed series, at its own position. +/// +/// The two windows decide different traffic — the inner one four grid points of its own grid, the +/// outer one two of its own — so a single total for the tree cannot produce both numbers. The inner +/// window reads the source as it arrives and hands over one chunk per chunk it took in; the outer +/// one collects those chunks, because the inner one keeps the metric name and its output can hold +/// two source series under one label set, and reports the one merged series its input ended with. +#[test] +fn each_node_records_its_own_metrics() { + let inner_grid = grid(0, 60_000, 4); + let outer_grid = grid(0, 120_000, 2); + let root = window_with( + window_with( + input(), + RangeFunction::LastOverTime, + 300_000, + None, + Arc::clone(&inner_grid), + ), + RangeFunction::LastOverTime, + 300_000, + Some(inner_grid), + outer_grid, + ); + let tree = NativeTree::try_new(root).unwrap(); + let set = ExecutionPlanMetricsSet::new(); + let mut execution = executor(&tree, pool(), &set); + let schema = raw_schema(); + execution + .push_raw(raw_chunk(&schema, &[0, 60_000], &[1.0, 2.0], false)) + .unwrap(); + execution + .push_raw(raw_chunk(&schema, &[120_000], &[3.0], false)) + .unwrap(); + execution + .push_raw(raw_chunk(&schema, &[180_000], &[4.0], true)) + .unwrap(); + let out = run(&mut execution); + // The outer window reports the two points of its grid once its input ended, in the one chunk of + // the series it merged from the inner window's pieces. + assert_eq!(addressed(&out), vec![(0, vec![Some(1.0), Some(3.0)])]); + assert!(out.last().unwrap().is_last); + + let outer = NodePath::root(); + let inner = outer.input(); + let source = inner.input(); + + // Three source chunks reach the inner window, which hands one chunk on per chunk it took in. + // The outer window takes those three pieces in and reports the one series they merge into. + assert_eq!(count(&set, &source, NodeMetric::INPUT_BATCHES), 3); + assert_eq!(count(&set, &source, NodeMetric::OUTPUT_BATCHES), 3); + assert_eq!(count(&set, &inner, NodeMetric::INPUT_BATCHES), 3); + assert_eq!(count(&set, &outer, NodeMetric::INPUT_BATCHES), 3); + assert_eq!(count(&set, &inner, NodeMetric::OUTPUT_BATCHES), 3); + assert_eq!(count(&set, &outer, NodeMetric::OUTPUT_BATCHES), 1); + // The grid points each window decided, which is its own output range: four on the inner grid + // and two on the outer one, which is the figure that tells the two nodes apart. + assert_eq!(count(&set, &inner, NodeMetric::EVALUATED_GRID_POINTS), 4); + assert_eq!(count(&set, &outer, NodeMetric::EVALUATED_GRID_POINTS), 2); + // The one series closes once, on the chunk carrying `is_last`. + assert_eq!(count(&set, &source, NodeMetric::COMPLETED_SERIES), 1); + assert_eq!(count(&set, &inner, NodeMetric::COMPLETED_SERIES), 1); + assert_eq!(count(&set, &outer, NodeMetric::COMPLETED_SERIES), 1); + // Per node, not one total for the tree. + assert_ne!( + count(&set, &inner, NodeMetric::EVALUATED_GRID_POINTS), + count(&set, &outer, NodeMetric::EVALUATED_GRID_POINTS) + ); +} + +/// An error returns the reservation while the execution is still alive, and a drop returns it too. +#[test] +fn an_error_releases_the_reservation() { + let tree = NativeTree::try_new(window(input(), grid(0, 60_000, 4))).unwrap(); + let pool = pool(); + let set = ExecutionPlanMetricsSet::new(); + let schema = raw_schema(); + + let mut execution = executor(&tree, Arc::clone(&pool), &set); + assert_eq!(pool.reserved(), 0); + assert_eq!( + execution.state().nodes_holding_windows(), + 1, + "a window that has not been released is what the drop below has to give back" + ); + execution + .push_raw(raw_chunk(&schema, &[0, 60_000], &[1.0, 2.0], false)) + .unwrap(); + assert!( + pool.reserved() > 0, + "the queued chunk has to be reserved for" + ); + // The chunk settles the first grid points, so it comes out; the series it belongs to has not + // ended, so the node still holds what it buffered for it. + let settled = execution.next_chunk().unwrap(); + assert!( + settled.is_some_and(|chunk| !chunk.is_last), + "a chunk that does not close its series was reported as closing it" + ); + let held = pool.reserved(); + assert!(held > 0, "the open series has to be reserved for"); + + // A second chunk of the same series whose timestamps do not increase: the evaluator rejects it + // after the node already held input for it. + execution + .push_raw(raw_chunk(&schema, &[0, 60_000], &[3.0, 4.0], true)) + .unwrap(); + let error = execution.next_chunk().unwrap_err(); + assert!(error.to_string().contains("not increasing"), "{error}"); + // Everything went back to the pool while the execution is alive: waiting for the drop would + // keep the query's bytes in use for as long as the caller holds the executor. + assert_eq!(pool.reserved(), 0); + assert_eq!(execution.state().reserved_bytes(), 0); + // The numbers above only say the reservation was returned. The state the bytes were taken for + // has to be gone too, or the executor keeps the evaluator's sample buffers alive while the pool + // reports them as free: the window is dropped, not only its accounting. + assert_eq!( + execution.state().nodes_holding_windows(), + 0, + "the stopped execution still holds a window's samples while its reservation reads zero" + ); + let current = NodePath::root().metric_name(NodeMetric::CURRENT_RESERVED_BYTES); + assert_eq!(gauge(&set, ¤t), 0); + let peak = NodePath::root().partitioned_metric_name(NodeMetric::PEAK_RESERVED_BYTES, 0); + assert!(gauge(&set, &peak) >= held); + // A stopped execution is not resumed. + let again = execution.next_chunk().unwrap_err(); + assert!( + again.to_string().contains("stopped after an earlier error"), + "{again}" + ); + + // Dropping an execution that never finished returns its bytes too. + let mut unfinished = executor(&tree, Arc::clone(&pool), &set); + unfinished + .push_raw(raw_chunk(&schema, &[0], &[1.0], false)) + .unwrap(); + let _ = unfinished.next_chunk().unwrap(); + assert!( + pool.reserved() > 0, + "the open series has to be reserved for" + ); + drop(unfinished); + assert_eq!(pool.reserved(), 0); +} + +/// A chunk the pool cannot admit fails the execution and gives back everything it already held. +/// +/// The window holds the first chunk's samples, so the second one is refused against a reservation +/// that is already standing: the failure has to hand that reservation back too, rather than leaving +/// the pool full for as long as the caller keeps the stopped executor alive. +#[test] +fn a_pool_that_cannot_admit_a_chunk_releases_what_it_held() { + let tree = NativeTree::try_new(window(input(), grid(0, 60_000, 4))).unwrap(); + let schema = raw_schema(); + let set = ExecutionPlanMetricsSet::new(); + // Room for the first chunk and what the window keeps of it, not for the second one. + let pool = bounded_pool(16 << 10); + let mut execution = executor(&tree, Arc::clone(&pool), &set); + + execution + .push_raw(raw_chunk(&schema, &[0], &[1.0], false)) + .unwrap(); + drop(execution.next_chunk().unwrap()); + let held = pool.reserved(); + assert!(held > 0, "the open series has to be reserved for"); + + // A hundred thousand samples are hundreds of kilobytes whatever the pool already holds. + let timestamps: Vec = (0..100_000).collect(); + let values = vec![1.0; 100_000]; + let error = execution + .push_raw(raw_chunk(&schema, ×tamps, &values, true)) + .expect_err("a chunk larger than the pool was admitted"); + assert!( + matches!(error, DataFusionError::ResourcesExhausted(_)), + "{error}" + ); + + // The chunk never entered the queue, and everything the execution held went back to the pool + // while the executor is still alive. + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + let current = NodePath::root().metric_name(NodeMetric::CURRENT_RESERVED_BYTES); + assert_eq!(gauge(&set, ¤t), 0); + let peak = NodePath::root().partitioned_metric_name(NodeMetric::PEAK_RESERVED_BYTES, 0); + assert!(gauge(&set, &peak) >= held); + // A stopped execution is not resumed. + let again = execution.next_chunk().unwrap_err(); + assert!( + again.to_string().contains("stopped after an earlier error"), + "{again}" + ); +} + +/// The malformed streams a streaming window refuses are refused by the collector too. +/// +/// The window below the map collects the whole fragment stream and hands it to +/// [`crate::grid_series::merge_same_labelset`], which sorts the rows by position and groups them by +/// label set. That is what makes two closed series that share a label set one series, and it is also +/// what would hide a stream that never was one series: runs that went backwards inside a series, and +/// an identity that changed inside a series, both come out as a plausible merged series with a +/// peaceful answer. A window that read the same stream as it arrived refuses it on the chunk that +/// breaks it, and so does the collector, before the chunk is kept. +/// +/// The stream below is a single series per case and the answer it would have produced is the one +/// nothing asked for: `3` for the first (the merge would sort 1 and 2 onto positions 0 and 1), and +/// two answers for the second. +#[test] +fn a_collector_refuses_the_malformed_streams_a_streaming_window_refuses() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let tree = || { + NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap() + }; + + let cases = [ + ( + "runs going backwards inside one series", + evaluated_chunk_of(&schema, named_metric("a", "x"), 1, vec![Some(1.0)], false), + evaluated_chunk_of(&schema, named_metric("a", "x"), 0, vec![Some(2.0)], true), + "not increasing", + ), + ( + "an identity changing inside one series", + evaluated_chunk_of(&schema, named_metric("a", "x"), 0, vec![Some(1.0)], false), + evaluated_chunk_of(&schema, named_metric("b", "y"), 1, vec![Some(2.0)], true), + "changed identity", + ), + ]; + for (what, first, second, phrase) in cases { + let pool = pool(); + let mut execution = executor(&tree(), Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + // The chunk that only opens the series is what a stream hands over first, and it is legal: + // the collector keeps it and reports nothing. + execution.push_evaluated(first).unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "{what}: the collector reported a piece before its input ended" + ); + execution.push_evaluated(second).unwrap(); + let error = match execution.next_chunk() { + Ok(None) => panic!("{what} was collected as if it were one series"), + Ok(Some(chunk)) => panic!("{what} reached the evaluator as {:?}", decided(&[chunk])), + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains(phrase), + "{what} was refused for another reason: {message}" + ); + // The failure released everything the chain held while the executor is still alive. + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + assert_eq!( + execution.state().nodes_holding_windows(), + 0, + "{what}: the stopped execution still holds a window's samples" + ); + // A stopped execution is not resumed. + let again = execution.next_chunk().unwrap_err(); + assert!( + again.to_string().contains("stopped after an earlier error"), + "{again}" + ); + } +} + +/// The streams a streaming window accepts are accepted by the collector too. +/// +/// One series arriving in chunks that move forward is what a scan hands over, and the chunk that +/// closes a series may carry no row at all. Both are legal to a streaming window, so both are legal +/// to the collector, which merges them into the one series they are and answers over it. The values +/// are the ones the audit's counterexamples would have produced had they been legal: the two forward +/// chunks hold 1 and 3 and sum to 4, and the close that carries no row leaves the 1 it follows. +#[test] +fn a_collector_accepts_forward_runs_and_a_close_without_a_row() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let tree = || { + NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap() + }; + + let cases = [ + ( + "two chunks moving forward", + vec![ + evaluated_chunk_of(&schema, named_metric("a", "x"), 0, vec![Some(-1.0)], false), + evaluated_chunk_of(&schema, named_metric("a", "x"), 1, vec![Some(-3.0)], true), + ], + vec![(0, Some(4.0))], + ), + ( + "a close that carries no row", + vec![ + evaluated_chunk_of(&schema, named_metric("a", "x"), 0, vec![Some(-1.0)], false), + evaluated_chunk_of(&schema, named_metric("a", "x"), 2, Vec::new(), true), + ], + vec![(0, Some(1.0))], + ), + ]; + for (what, chunks, expected) in cases { + let mut execution = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new()); + for chunk in chunks { + execution.push_evaluated(chunk).unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "{what}: the collector reported a piece before its input ended" + ); + } + let out = execution + .finish() + .unwrap_or_else(|error| panic!("{what} was refused: {error}")); + assert_eq!(decided(&out), expected, "{what}"); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "{what}: the one merged series closed more than once" + ); + } +} + +/// An input that ends inside a series is reported by a window that collects its input too. +/// +/// The collector holds the fragments until the input ends and closes every series it merges with a +/// synthetic `is_last`, so a series the caller never closed would look exactly like one that closed +/// afterwards: the merge would answer it and the barrier would hide the truncation. Nothing is +/// merged here, because the executor refuses the truncation before the flush runs. The check is the +/// input node's own, which follows what it handed over, and it is the only one needed: every closing +/// chunk the caller sent has already reached the collector, which wrote down the series it left open. +/// +/// A root map reports the same truncation, and +/// [`an_input_that_ends_mid_series_is_reported_without_a_window`] is that case on its own; this one +/// is the barrier's, where the flush would have run before the report if the check came later. +#[test] +fn an_input_that_ends_inside_a_collected_series_is_reported() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let tree = NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let pool = pool(); + let set = ExecutionPlanMetricsSet::new(); + let mut execution = executor(&tree, Arc::clone(&pool), &set); + + // One piece of a series that never says it ended: the collector keeps it and reports nothing. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(-1.0)], + false, + )) + .unwrap(); + assert!( + execution + .next_chunk() + .expect("the fragment is legal") + .is_none(), + "the collector reported a piece before its input ended" + ); + assert!( + execution.state().reserved_bytes() > 0, + "the collected fragment is not charged for" + ); + + let error = execution + .finish() + .expect_err("an input that ended inside a collected series was accepted"); + assert!( + error + .to_string() + .contains("ended before the last chunk of a series"), + "the truncation was reported as {error}" + ); + // Nothing was merged and nothing was answered, and everything went back while the executor is + // alive. + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + assert_eq!( + execution.state().nodes_holding_windows(), + 0, + "the stopped execution still holds a window's samples" + ); + // The window's own gauge, which is the node that collected the fragment, reads zero. + let current = NodePath::root().metric_name(NodeMetric::CURRENT_RESERVED_BYTES); + assert_eq!(gauge(&set, ¤t), 0); + // A stopped execution is not resumed. + let again = execution.next_chunk().unwrap_err(); + assert!( + again.to_string().contains("stopped after an earlier error"), + "{again}" + ); +} + +/// Two series that a map turns into one label set are merged before the window above reads them. +/// +/// `a{job="x"}` and `b{job="x"}` arrive whole, so each says it is the last chunk of its own source +/// series, and `abs` drops the name, so both become `{job="x"}`. The two hold their values at +/// different positions, so they are one series with a hole each and the answer is the window over +/// the merged series: `sum_over_time` sees 1 and 3 and answers 4, `count_over_time` answers 2. A +/// window that read the two as two series would answer twice, once per source series, and close +/// twice. +/// +/// The window above the map is the node that merges: its input can hold two series under one label +/// set, so it collects the whole input, merges each label set once with +/// [`crate::grid_series::merge_same_labelset`], and only then evaluates. Nothing comes out of it +/// while the pieces arrive, which is the price of merging; what comes out is one series. +#[test] +fn two_series_onto_one_label_set_after_a_map_are_merged_into_one_series() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + // One map and two maps: the label set is rewritten at the first one either way, and the second + // only rewrites it again, so both shapes are one affected window over one rewritten set. + let shapes = |function: RangeFunction| { + [ + ( + "a map", + NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + function, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(), + ), + ( + "two maps", + NativeTree::try_new(window_with( + map( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + vec![MathOp::Abs], + ), + function, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(), + ), + ] + }; + + for (function, expected) in [ + // The two values at inner positions 0 and 1 both fall in the one window of the outer grid. + (RangeFunction::SumOverTime, 4.0), + (RangeFunction::CountOverTime, 2.0), + ] { + for (shape, tree) in shapes(function) { + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + // Each source series is closed properly, and each holds its value where the other holds + // none, so the pair is one logical series and one answer. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(1.0), None], + true, + )) + .unwrap(); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("b", "x"), + 0, + vec![None, Some(3.0)], + true, + )) + .unwrap(); + // Nothing settles while the pieces arrive: the merged series is what the window evaluates, + // and it does not exist before the input ends. + assert!( + execution.next_chunk().unwrap().is_none(), + "{shape} with {function:?} reported a piece it had not merged yet" + ); + let out = execution.finish().expect("the merged series evaluates"); + assert_eq!( + decided(&out), + vec![(0, Some(expected))], + "{shape} with {function:?} did not answer over the merged series" + ); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "{shape} with {function:?} closed the one merged series more than once" + ); + } + } +} + +/// Two closed series that hold a value at the same position are still refused under one label set. +/// +/// The pair below maps into one label set and both hold a value at position 0 of the field, which +/// no merge resolves and Prometheus rejects for two series whose label sets became equal. The +/// refusal is the map's own, before the window above evaluates anything, and it names the position +/// rather than a closed label set: a closed source series is not a closed label set. +#[test] +fn two_series_onto_one_label_set_after_a_map_are_rejected() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let tree = || { + NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::AvgOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap() + }; + let schema = evaluated_schema(Arc::clone(&inner_grid)); + + let mut execution = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(1.0), None], + true, + )) + .unwrap(); + // The first series closed, and the window above collects it rather than reporting it. + assert!( + execution.next_chunk().unwrap().is_none(), + "the window above the map reported a piece before its input ended" + ); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("b", "x"), + 0, + vec![Some(3.0), Some(4.0)], + true, + )) + .unwrap(); + let error = match execution.next_chunk() { + Ok(None) => panic!("the second series was accepted at a position the first one holds"), + Ok(Some(chunk)) => panic!( + "two values at one position were handed on: {:?}", + decided(&[chunk]) + ), + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("job"), + "the rejection does not name the label set: {message}" + ); + assert!( + message.contains("position 0"), + "the rejection does not name the shared position: {message}" + ); +} + +/// An empty tag and an absent one are two source series and one canonical label set. +/// +/// `requests{host="web-1", zone=""}` and `requests{host="web-1"}` are distinct series to a scan: an +/// empty tag value is a present label, an absent one is not. `last_over_time` keeps the metric name +/// and every tag, so the inner window hands both on as two pieces, each closing its own source +/// series, and the two pieces carry different metrics. The outer window is the node that merges +/// them: a consumer compares label sets without the empty value, so the two are one series there. +/// Merging first is what makes the outer `sum_over_time` answer 4 at the second point -- the two +/// pieces hold 1 at the first point and 3 at the second -- where evaluating each piece on its own +/// would answer 3. +#[test] +fn an_empty_tag_and_an_absent_one_are_one_label_set_to_the_window_above() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(0, 60_000, 2); + let inner = || { + // A one-millisecond window on a 60 s grid, so every point reads only the sample at its own + // timestamp and each source series decides one point. + window_with( + input(), + RangeFunction::LastOverTime, + 1, + None, + Arc::clone(&inner_grid), + ) + }; + let chain = || { + window_with( + inner(), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + ) + }; + let schema = raw_schema(); + let push_both = |execution: &mut NativeExecutor| { + execution + .push_raw(raw_chunk_of( + &schema, + b"series-a", + host_metric(Some("")), + &[0], + &[1.0], + true, + )) + .unwrap(); + execution + .push_raw(raw_chunk_of( + &schema, + b"series-b", + host_metric(None), + &[60_000], + &[3.0], + true, + )) + .unwrap(); + }; + + // The inner window alone hands both series on, and they are two series: the empty tag survives + // `last_over_time`, which keeps the name and every tag. + let inner_tree = NativeTree::try_new(inner()).unwrap(); + let mut inner_execution = executor(&inner_tree, pool(), &ExecutionPlanMetricsSet::new()); + push_both(&mut inner_execution); + let pieces = run(&mut inner_execution); + assert_eq!( + pieces.len(), + 2, + "the inner window did not hand both series on" + ); + assert_ne!( + pieces[0].series.metric, pieces[1].series.metric, + "the two source series were not two series to `last_over_time`" + ); + + // The chain merges them before the outer window reads them. + let tree = NativeTree::try_new(chain()).unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + push_both(&mut execution); + let out = run(&mut execution); + assert_eq!( + decided(&out), + vec![(0, Some(1.0)), (1, Some(4.0))], + "the outer window did not answer over the merged label set" + ); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "the merged label set closed more than once" + ); +} + +/// A window that collects its whole input gives every byte back when the execution closes. +/// +/// The fragments it holds, the workspace it merges them with and the chunks it queued while +/// flushing are all charged while they live, so the pool never admits memory the node still owns. +/// The close releases all of them, and the figures read zero while the executor is still alive. +#[test] +fn a_window_that_merges_its_input_releases_what_it_collected() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let tree = NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let set = ExecutionPlanMetricsSet::new(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &set); + for (name, values) in [("a", vec![Some(1.0), None]), ("b", vec![None, Some(3.0)])] { + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric(name, "x"), + 0, + values, + true, + )) + .unwrap(); + } + // Pulling takes both fragments out of the queue and into the window, which charges them. + assert!( + execution.next_chunk().unwrap().is_none(), + "the collecting window reported a piece" + ); + let held = execution.state().reserved_bytes(); + let window_held = execution.state().runtimes[0].buffer.reservation.size(); + assert!(held > 0, "the collected input is not charged for"); + assert_eq!( + held, + pool.reserved(), + "the state and the pool disagree on what the collected input holds" + ); + + let out = execution.finish().expect("the merged series evaluates"); + assert_eq!(decided(&out), vec![(0, Some(4.0))]); + // The fragments, the workspace, the queue and the evaluator are all gone. + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + assert_eq!(execution.state().nodes_holding_windows(), 0); + let current = NodePath::root().metric_name(NodeMetric::CURRENT_RESERVED_BYTES); + assert_eq!(gauge(&set, ¤t), 0); + let peak = NodePath::root().partitioned_metric_name(NodeMetric::PEAK_RESERVED_BYTES, 0); + assert!( + gauge(&set, &peak) >= window_held, + "the peak does not cover the input the window collected" + ); +} + +/// A pool that cannot admit what a collecting chain holds fails the execution and gives everything +/// back. +/// +/// The window collects its whole input, so the fragment it keeps costs more than the chunk it was +/// made from: the pool below holds room for the chunk and no more, so one of the charges the chain +/// makes fails. Whichever it is, the failure hands every reservation back while the executor is +/// still alive, and the evaluators and the collected fragments go with it. +#[test] +fn a_pool_that_cannot_admit_a_collected_fragment_releases_what_it_held() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let tree = NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let chunk = evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(1.0), None], + true, + ); + // Room for the chunk the source queue holds, which is what the pool admits first, and nothing + // for the label sets the map records or the fragment the window collects. + let pool = bounded_pool(evaluated_bytes(&chunk) + 1); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(chunk) + .expect("the source queue holds the chunk"); + + let error = execution + .next_chunk() + .expect_err("the chain was admitted by a pool that cannot hold what it makes of the chunk"); + assert!( + matches!(error, DataFusionError::ResourcesExhausted(_)), + "{error}" + ); + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + assert_eq!( + execution.state().nodes_holding_windows(), + 0, + "the stopped execution still holds a window's samples" + ); +} + +/// A merge whose workspace the pool cannot admit stops the execution and gives everything back. +/// +/// The fragments are admitted one by one as they arrive, so their charge is not what fails here: +/// what fails is the workspace the merge needs, taken in `finish` before the helper runs. The first +/// run measures what the collected input holds where the pool admits it; the second is given that +/// much and a little over -- enough for every fragment, not for the workspace, which charges at +/// least one `(position, member, row)` triple and one position per row. The failure releases the +/// fragments, the evaluators and the queues while the executor is still alive. +#[test] +fn a_pool_that_cannot_admit_the_merge_workspace_releases_what_it_held() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(60_000, 60_000, 1); + let tree = || { + NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::SumOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap() + }; + let schema = evaluated_schema(Arc::clone(&inner_grid)); + // Two closed source series, each holding its value where the other holds none, so the fragments + // do merge and the merge is what needs the workspace. + let push_both = |execution: &mut NativeExecutor| { + for (name, values) in [("a", vec![Some(-1.0), None]), ("b", vec![None, Some(-3.0)])] { + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric(name, "x"), + 0, + values, + true, + )) + .unwrap(); + } + }; + + // What the chain holds once its input is collected, measured where the pool admits it. + let mut reference = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new()); + push_both(&mut reference); + assert!( + reference.next_chunk().unwrap().is_none(), + "the collecting window reported a piece" + ); + let measured = reference.state().reserved_bytes(); + assert!(measured > 0, "the collected input is not charged for"); + drop(reference); + + // Room for exactly what the collected input holds, and not for the merge's workspace. + let pool = bounded_pool(measured + 64); + let mut execution = executor(&tree(), Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + push_both(&mut execution); + assert!( + execution.next_chunk().unwrap().is_none(), + "the collecting window reported a piece" + ); + let collected = execution.state().reserved_bytes(); + assert_eq!( + collected, measured, + "the collected input was charged differently the second time, so the pool is not sized for it" + ); + assert_eq!(collected, pool.reserved()); + + let error = execution + .finish() + .expect_err("the merge ran without its workspace admitted"); + assert!( + matches!(error, DataFusionError::ResourcesExhausted(_)), + "{error}" + ); + // Everything the chain held went back while the executor is alive: the fragments, the + // evaluator and both queues. + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + assert_eq!( + execution.state().nodes_holding_windows(), + 0, + "the stopped execution still holds a window's samples" + ); + // A stopped execution is not resumed. + let again = execution.next_chunk().unwrap_err(); + assert!( + again.to_string().contains("stopped after an earlier error"), + "{again}" + ); +} + +/// A window that drops empty-valued labels cannot hand two series on under one label set either. +/// +/// `{host="web-1", zone=""}` and `{host="web-1"}` are two series to the scan: an empty tag value is +/// a present label, an absent one is not. `rate` drops the metric name and the empty-valued labels, +/// so both become `{host="web-1"}` and each covers the same grid; answering would hand the whole +/// grid out twice under the same labels and the same timestamps. No map is involved, so the +/// rejection has to be the node's own — a check at the root would be too late, because the window +/// above would already have evaluated the two as two series. +/// +/// The two cover the same positions of the same field, which is the collision no merge resolves: +/// the refusal names the position, not a closed label set, because closing a source series says +/// nothing about the label set it shared with another one. `rate` needs two samples, so neither +/// series decides position 0 -- it holds the single sample at its own timestamp -- and both decide +/// positions 1 to 4. The first position both occupy is therefore 1. +#[test] +fn two_series_onto_one_label_set_after_a_window_are_rejected() { + let out_grid = grid(0, 60_000, 5); + let tree = NativeTree::try_new(window_with( + input(), + RangeFunction::Rate, + 300_000, + None, + Arc::clone(&out_grid), + )) + .unwrap(); + let schema = raw_schema(); + let counter = [0, 60_000, 120_000, 180_000, 240_000]; + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_raw(raw_chunk_of( + &schema, + b"series-a", + host_metric(Some("")), + &counter, + &[1.0, 2.0, 3.0, 4.0, 5.0], + true, + )) + .unwrap(); + let first = execution + .next_chunk() + .unwrap() + .expect("the first series settles the grid"); + // The decided positions of the first series, which are the ones the second one has to share. + assert_eq!( + first.series.time.runs().count(), + 1, + "the first series decided more than one run" + ); + assert_eq!( + first.series.time.first_position(), + Some(1), + "`rate` decided a position the single sample at 0 cannot settle" + ); + assert!(first.is_last); + + // The second series is a different one to the scan and the same one to `rate`. + execution + .push_raw(raw_chunk_of( + &schema, + b"series-b", + host_metric(None), + &counter, + &[6.0, 7.0, 8.0, 9.0, 10.0], + true, + )) + .unwrap(); + let error = match execution.next_chunk() { + Ok(None) => panic!("the second series settled nothing at all"), + Ok(Some(chunk)) => panic!( + "a second series left the window under the first one's label set: {:?}", + decided(&[chunk]) + ), + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("host"), "{message}"); + assert!(message.contains("web-1"), "{message}"); + assert!( + message.contains("position 1"), + "the rejection does not name the first position both series hold: {message}" + ); + + // Two series carrying different hosts stay distinct, which is what a rate over a label that + // survives the rewrite looks like. + let mut distinct = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + distinct + .push_raw(raw_chunk_of( + &schema, + b"series-a", + host_metric(Some("")), + &counter, + &[1.0, 2.0, 3.0, 4.0, 5.0], + true, + )) + .unwrap(); + distinct + .push_raw(raw_chunk_of( + &schema, + b"series-b", + MetricName::try_new( + Some("requests".into()), + vec![Label { + name: "host".into(), + value: "web-2".into(), + }], + ) + .unwrap(), + &counter, + &[6.0, 7.0, 8.0, 9.0, 10.0], + true, + )) + .unwrap(); + let out = run(&mut distinct); + assert_eq!(out.len(), 2, "two series over two hosts are two series"); +} + +/// A chunk that holds a value where the same field already holds one is rejected. +/// +/// Neither piece closes its series, and neither needs to: the refusal is about the positions +/// themselves. `v0` would carry two values at position 0 under one label set, which no merge can +/// resolve, so the node refuses rather than picking one. +#[test] +fn two_series_holding_a_value_at_one_position_are_rejected() { + let map_grid = grid(0, 60_000, 2); + let tree = NativeTree::try_new(map( + evaluated_input(Arc::clone(&map_grid)), + vec![MathOp::Abs], + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&map_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + for (name, values) in [ + ("a", vec![Some(-1.0), Some(-2.0)]), + ("b", vec![Some(-3.0), Some(-4.0)]), + ] { + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric(name, "x"), + 0, + values, + false, + )) + .unwrap(); + } + // The first piece is fine on its own: nothing has occupied any position yet. + let first = execution + .next_chunk() + .unwrap() + .expect("the first piece is handed on as it arrives"); + assert_eq!(sample_values(&first), vec![Some(1.0), Some(2.0)]); + // The second one puts a second value of the same field at position 0. + let error = match execution.next_chunk() { + Ok(None) => panic!("the map produced no chunk at all"), + Ok(Some(chunk)) => panic!( + "a second series holding a value at the same position was handed on: {:?}", + decided(&[chunk]) + ), + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("job"), "{message}"); + assert!(message.contains("field 0"), "{message}"); + assert!(message.contains("position 0"), "{message}"); +} + +/// Two closed series under one label set are two pieces of one logical series. +/// +/// `a{job="x"}` and `b{job="x"}` are two series to the input and one label set to `abs`, which drops +/// the name. Each holds a value where the other holds none, and each says it is the last chunk of +/// its own source series — which is what `is_last` means here: the source series ended, not the +/// label set. The map hands both pieces on, and merging the two pieces with +/// [`crate::grid_series::merge_same_labelset`] gives the one series the data has, which is what a +/// caller holding the root's pieces does. +#[test] +fn a_merge_across_two_closed_series_is_the_root_piece_stream() { + let map_grid = grid(0, 60_000, 2); + let tree = NativeTree::try_new(map( + evaluated_input(Arc::clone(&map_grid)), + vec![MathOp::Abs], + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&map_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + // Each source series is closed properly: the pieces hold disjoint positions of field 0 and both + // end their series. + for (name, values) in [("a", vec![Some(-1.0), None]), ("b", vec![None, Some(-3.0)])] { + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric(name, "x"), + 0, + values, + true, + )) + .unwrap(); + } + // Both pieces come out of the root, one per source series, and neither is refused. + let mut pieces = Vec::new(); + loop { + match execution.next_chunk() { + Ok(Some(chunk)) => pieces.push(chunk), + Ok(None) => break, + Err(error) => panic!("a closed source series was refused: {error}"), + } + } + pieces.extend( + execution + .finish() + .expect("the map holds no collected input"), + ); + assert_eq!(pieces.len(), 2, "the root did not hand over both pieces"); + assert!( + pieces.iter().all(|piece| piece.is_last), + "a piece of a closed source series did not close it" + ); + + // The one series the two pieces are, merged the way a consumer of the root's pieces merges + // them: positions 0 and 1 of field 0, holding 1 and 3. + let merged = merge_same_labelset( + &evaluated_schema(Arc::clone(&map_grid)), + pieces.into_iter().map(|piece| piece.series).collect(), + ) + .expect("the two pieces hold disjoint positions"); + assert_eq!( + merged.len(), + 1, + "the two pieces did not merge into one logical series" + ); + assert_eq!( + merged[0].metric, + { + let mut mapped = named_metric("a", "x"); + mapped.remove("__name__"); + mapped + }, + "the merged series does not carry the label set both pieces produced" + ); + assert_eq!( + merged[0] + .fields + .get(0) + .expect("one field") + .iter() + .map(|sample| match sample { + SampleRef::Float(value) => Some(value), + _ => None, + }) + .collect::>(), + vec![Some(1.0), Some(3.0)], + "the merged series does not hold both pieces' values at their positions" + ); +} + +/// Two open chunks that share a label set are accepted where no position of a field collides. +/// +/// `a{job="x"}` and `b{job="x"}` are two series to the input and one label set to `abs`, which +/// drops the name. Neither piece says it is the last chunk of its series, so the second one arrives +/// while the first is still open. The two occupy disjoint positions of field 0 — a value at 0 and a +/// value at 3 — so the label-set check hands both on. +/// +/// The ordering is contiguous because that is the only ordering this runtime executes: the stream +/// below is not one a scan produces, since it never closes `a`, and what is checked here is the +/// occupancy the check reads rather than a delivery order. +/// +/// This is acceptance by that check, and it is not a merge: this layer passes every chunk through +/// as it arrives and keeps no sample state that would combine two series. Merging two series under +/// one label set is [`crate::grid_series::merge_same_labelset`]'s job, and nothing here performs +/// it. +#[test] +fn two_open_series_that_share_a_label_set_are_accepted_when_no_position_collides() { + let map_grid = grid(0, 60_000, 4); + let tree = NativeTree::try_new(map( + evaluated_input_with_ordering(Arc::clone(&map_grid), SeriesOrdering::Contiguous), + vec![MathOp::Abs], + )) + .expect("a map-only tree is legal over a contiguous input"); + let schema = evaluated_schema(Arc::clone(&map_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(-1.0), None], + false, + )) + .unwrap(); + let first = execution + .next_chunk() + .unwrap() + .expect("the first open chunk is handed on as it arrives"); + assert_eq!(sample_values(&first), vec![Some(1.0), None]); + assert_eq!(series_start(&first), 0); + + // Positions 2 and 3 of field 0, holding a value at 3 only: no position of the field is + // occupied twice, so nothing the check looks at is claimed by both series. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("b", "x"), + 2, + vec![None, Some(-3.0)], + false, + )) + .unwrap(); + let second = execution + .next_chunk() + .unwrap_or_else(|error| panic!("the second open chunk was refused: {error}")) + .expect("the second open chunk is handed on too"); + assert_eq!(sample_values(&second), vec![None, Some(3.0)]); + assert_eq!(series_start(&second), 2); + assert!( + execution.next_chunk().unwrap().is_none(), + "the map handed on more than the two chunks it was given" + ); +} + +/// A `NaN` is a value and a missing sample is not, so only the `NaN` occupies its position. +/// +/// `abs` drops the metric name, so the two series below are one label set to the map, which is where +/// the two are read against each other. A position holding no sample is a position the series never +/// claimed, and another series may fill it; a position holding a `NaN` is decided and holds a value, +/// like any other, so a second value there is the collision no merge resolves. +#[test] +fn a_nan_occupies_its_position_where_a_missing_sample_does_not() { + let map_grid = grid(0, 60_000, 1); + let tree = || { + NativeTree::try_new(map( + evaluated_input_with_ordering(Arc::clone(&map_grid), SeriesOrdering::Contiguous), + vec![MathOp::Abs], + )) + .unwrap() + }; + let schema = evaluated_schema(Arc::clone(&map_grid)); + + // The first series' sample at position 0, the second series' sample there, and whether the two + // can share the label set `abs` leaves them. + let cases = [ + (vec![Some(f64::NAN)], vec![Some(1.0)], false), + (vec![Some(1.0)], vec![Some(f64::NAN)], false), + (vec![Some(f64::NAN)], vec![None], true), + (vec![Some(1.0)], vec![None], true), + (vec![None], vec![Some(f64::NAN)], true), + ]; + // Both chunks are open and neither needs to close: what the check reads is which positions a + // chunk occupies, and that does not depend on `is_last`. + let drain = |execution: &mut NativeExecutor| -> Result<(), DataFusionError> { + while execution.next_chunk()?.is_some() {} + Ok(()) + }; + for (first, second, accepted) in cases { + let mut execution = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + first.clone(), + false, + )) + .unwrap(); + drain(&mut execution).expect("the first open chunk is handed on"); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("b", "x"), + 0, + second.clone(), + false, + )) + .unwrap(); + let outcome = drain(&mut execution); + assert_eq!( + outcome.is_ok(), + accepted, + "{first:?} and {second:?} were {}: {:?}", + if accepted { "refused" } else { "handed on" }, + outcome.err(), + ); + } +} + +/// One series arriving in pieces is not two series: the pieces are merged into the one series they +/// are, and the window above reports it once. +/// +/// The map is passed twice for one label set, so the window above collects both pieces and merges +/// them with [`crate::grid_series::merge_same_labelset`] before it evaluates: the two pieces hold +/// one position each, the window across them needs both, and answering per piece would close the +/// first series in the evaluator before the second arrived. +#[test] +fn a_series_that_continues_where_it_ended_is_not_a_second_series() { + let inner_grid = grid(0, 60_000, 4); + let outer_grid = grid(180_000, 60_000, 1); + let tree = NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), vec![MathOp::Abs]), + RangeFunction::AvgOverTime, + 360_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 0, + vec![Some(-1.0), None], + false, + )) + .unwrap(); + // Nothing comes out of the collecting window before its input ended, and the piece here does + // not end it. + while let Some(chunk) = execution.next_chunk().unwrap() { + assert!( + !chunk.is_last, + "the series cannot close before its last chunk" + ); + } + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("a", "x"), + 2, + vec![None, Some(-3.0)], + true, + )) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + decided(&out), + vec![(0, Some(2.0))], + "the two chunks of one series are one series to the window above the map" + ); + assert!(out.last().unwrap().is_last); +} + +/// A window reserves what it retains, not what it has consumed. +/// +/// A long series is fed one sample per chunk to a pool with a fixed budget, pulling and dropping the +/// result of every chunk and closing only on the last one. The same one-sample-per-chunk input +/// leaves the evaluator's storage at the same size however many chunks it arrives in, so the peak +/// reservation is the same whether the data is split into 64 chunks or into 1024, and it stays a +/// small constant rather than the running total of what arrived. A figure charged per chunk +/// consumed would grow with the chunk count and exhaust the budget the first chunks fit in. +#[test] +fn a_window_reserves_what_it_retains_not_what_it_consumed() { + // A 1 ms window on a 1 ms grid, so every chunk's sample is out of the window of the next one. + let tree = || { + NativeTree::try_new(window_with( + input(), + RangeFunction::LastOverTime, + 1, + None, + grid(0, 1, 1024), + )) + .unwrap() + }; + let schema = raw_schema(); + + let peak = |count: usize| { + let pool = bounded_pool(32 << 10); + let mut execution = executor(&tree(), Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + let mut peak = 0; + for index in 0..count { + execution + .push_raw(raw_chunk( + &schema, + &[index as i64], + &[index as f64], + index + 1 == count, + )) + .unwrap_or_else(|error| { + panic!("chunk {index} of the {count}-chunk series did not fit: {error}") + }); + // The output of every chunk is dropped: a consumer reading the series as it arrives + // never holds it, and neither may the node. + drop(execution.next_chunk().unwrap_or_else(|error| { + panic!("chunk {index} of the {count}-chunk series did not fit: {error}") + })); + let held = execution.state().reserved_bytes(); + assert_eq!( + held, + pool.reserved(), + "chunk {index} of {count}: the state and the pool disagree on what is held" + ); + assert!( + held <= (8 << 10), + "chunk {index} of {count} holds {held} bytes of a 1 ms window over one sample" + ); + peak = peak.max(held); + } + peak + }; + let short = peak(64); + let long = peak(1024); + assert_eq!( + short, long, + "the reservation grew with the number of chunks the same series arrived in" + ); +} + +/// A window's retention is per field, as the evaluator's storage is. +/// +/// The same one-sample-per-chunk input goes to a window over one float field, over two and over +/// three. Every field costs its own timestamps, values and row slot, so the reservation a window +/// reaches grows with the width of the series; a figure counting one timestamp and one float per +/// row, or one field per series, would not. +#[test] +fn a_window_retains_the_samples_of_every_field() { + // The most the window ever retained, over the same input and the same short window. + let peak = |width: usize| -> usize { + let tree = NativeTree::try_new(window_over_fields(width, grid(0, 1, 1024))).unwrap(); + let schema = raw_schema_of(field_count(width)); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + let (count, mut peak) = (8, 0); + for index in 0..count { + execution + .push_raw(raw_chunk_repeated( + &schema, + &[index as i64], + &[index as f64], + index + 1 == count, + )) + .unwrap(); + drop(execution.next_chunk().unwrap()); + peak = peak.max(execution.state().reserved_bytes()); + } + peak + }; + let (one, two, three) = (peak(1), peak(2), peak(3)); + assert!( + two > one, + "a window over two fields reserved {two} bytes, no more than the {one} bytes a window over \ + one field reserves" + ); + assert!( + three > two, + "a window over three fields reserved {three} bytes, no more than the {two} bytes a window \ + over two fields reserves" + ); +} + +/// A chunk that produces nothing still leaves the table this node emitted into in its hands. +/// +/// The first chunk decided both grid points and the window handed them out, so the table holds a +/// label set and its runs. The second chunk decides nothing — every point was already decided — so +/// the window yields no chunk, but it has given none of that history back: the reservation has to +/// keep covering the table, not drop it. +#[test] +fn a_chunk_with_no_output_keeps_charging_the_emitted_table() { + let tree = NativeTree::try_new(window_with( + input(), + RangeFunction::LastOverTime, + 1, + None, + grid(0, 60_000, 2), + )) + .unwrap(); + let schema = raw_schema(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + + execution + .push_raw(raw_chunk(&schema, &[0, 60_000], &[1.0, 2.0], false)) + .unwrap(); + let first = execution + .next_chunk() + .unwrap() + .expect("the first chunk decided both points"); + assert_eq!(sample_values(&first), vec![Some(1.0), Some(2.0)]); + drop(first); + let after_output = execution.state().reserved_bytes(); + assert!( + after_output > 0, + "the window handed out a position, which it has to hold" + ); + assert_eq!(after_output, pool.reserved()); + + // Both points are decided, so this chunk decides nothing and the window yields no chunk. + execution + .push_raw(raw_chunk(&schema, &[120_000], &[3.0], false)) + .unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "a chunk after every point was decided produced a chunk" + ); + assert!( + execution.state().reserved_bytes() > 0, + "a chunk with no output released the bytes the emitted table still holds" + ); + assert_eq!(execution.state().reserved_bytes(), pool.reserved()); +} + +/// A window cannot run over an interleaved input, and the runtime says so before reading a chunk. +/// +/// The binding refuses a window whose own input declares the ordering, and the runtime refuses the +/// ordering itself -- which is what catches a window over a map over the source, where the binding +/// only sees the map. A tree with no window above the source is refused by the same check, since +/// every node here reads one series at a time; [`an_interleaved_input_is_refused_without_a_window`] +/// is that case on its own. +#[test] +fn a_window_over_an_interleaved_input_is_rejected() { + let inner_grid = grid(0, 60_000, 2); + let outer_grid = grid(0, 120_000, 1); + let tree = NativeTree::try_new(window_with( + evaluated_input_with_ordering(Arc::clone(&inner_grid), SeriesOrdering::Interleaved), + RangeFunction::LastOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )); + // A window directly over an interleaved source is rejected when the tree is bound: the + // binding is where the ordering is known, and refusing there fails before a chunk is read. + let error = tree.expect_err("a window over an interleaved input must not bind"); + // A rejected tree is a compiler defect rather than a query error, so the binding reports it as + // `Internal`; what this pins is that the refusal names the ordering it came from rather than + // the node kind, since the same check refuses every input that declares it. + assert!(matches!(error, DataFusionError::Internal(_)), "{error}"); + let message = error.to_string(); + assert!(message.contains("interleaved"), "{message}"); + + // A map between them is not caught by the binding, which only looks at a window's own input: + // the map passes chunks through in the order they arrive, so the window still reads an + // interleaved stream. That is what this runtime checks for itself. + let tree = NativeTree::try_new(window_with( + map( + evaluated_input_with_ordering(Arc::clone(&inner_grid), SeriesOrdering::Interleaved), + vec![MathOp::Abs], + ), + RangeFunction::LastOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .expect("the binding accepts a window over a map"); + let error = + match NativeExecutionState::try_new(&tree, pool(), &ExecutionPlanMetricsSet::new(), 0) { + Ok(_) => panic!("the runtime accepted an interleaved input under a window"), + Err(error) => error, + }; + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "{error}" + ); + let message = error.to_string(); + assert!(message.contains("Interleaved"), "{message}"); + assert!(message.contains("root.input"), "{message}"); +} + +/// An interleaved input is refused with no window above it to notice it. +/// +/// A map over an input has no evaluator, so nothing in the tree reads one series to its `is_last`. +/// The sequence the input node cannot tell from a whole one is `a{job="x"}` open, `b{job="y"}` +/// closed, and the end of the input: the flag that reports a series left open would say the last +/// chunk closed its series and `finish` would accept an input whose first series never closed. The +/// runtime therefore refuses the ordering when the state is built, before a chunk is read, instead +/// of reporting a truncation it cannot see. +#[test] +fn an_interleaved_input_is_refused_without_a_window() { + let map_grid = grid(0, 60_000, 2); + let tree = NativeTree::try_new(map( + evaluated_input_with_ordering(Arc::clone(&map_grid), SeriesOrdering::Interleaved), + vec![MathOp::Abs], + )) + .expect("the binding accepts a map over an interleaved input"); + let error = + match NativeExecutionState::try_new(&tree, pool(), &ExecutionPlanMetricsSet::new(), 0) { + Ok(_) => panic!("the runtime accepted an interleaved input with no window above it"), + Err(error) => error, + }; + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "{error}" + ); + let message = error.to_string(); + assert!(message.contains("Interleaved"), "{message}"); + assert!(message.contains("root.input"), "{message}"); +} + +/// A series that produced values under a label set, followed by one that produced none under the +/// same label set, is not a collision: the second contributes no sample, so the answer is just the +/// first one's values. +/// +/// This is the other order of [`two_series_with_no_result_do_not_collide`]: a series that produced +/// values under a label set, followed by one that produced none. The empty one occupies no +/// position, so it records nothing and changes nothing, and the answer is the first one's values. +/// +/// The first series closed, which closes the source series and not the label set: the empty series +/// is a later one that also answers empty, and +/// [`an_empty_closing_chunk_of_another_series_does_not_close_a_label_set`] is its mirror image. +#[test] +fn a_series_with_no_result_after_a_closed_one_is_passed_over() { + let map_grid = grid(0, 60_000, 1); + let tree = NativeTree::try_new(map( + evaluated_input(Arc::clone(&map_grid)), + vec![MathOp::Abs], + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&map_grid)); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + + // `abs` drops the name, so both series arrive under the same label set. The first holds a + // value; the second holds none at all and has to be passed over rather than refused. + for (name, values) in [("a", vec![Some(-7.0)]), ("b", vec![None])] { + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric(name, "x"), + 0, + values, + true, + )) + .unwrap(); + } + + let mut handed = Vec::new(); + loop { + match execution.next_chunk() { + Ok(Some(chunk)) => handed.push(decided(&[chunk])), + Ok(None) => break, + Err(error) => panic!("an empty series after a closed one was refused: {error}"), + } + } + assert!( + handed + .iter() + .flatten() + .any(|(_, value)| *value == Some(7.0)), + "the first series' value is missing: {handed:?}" + ); +} + +/// An empty closing chunk of one series does not close a label set another series is still filling. +/// +/// `abs` drops the metric name, so `a{job="x"}` and `b{job="x"}` are one label set to the map. `b` +/// holds no row at all: its closing chunk closes a series that contributed nothing, so it must not +/// close the set `a` opened, or `a`'s continuation would be refused for a label set that `a` itself +/// never closed. Two series back to back are what contiguous input hands over, so this is a stream +/// the runtime executes rather than an ordering it refuses. +#[test] +fn an_empty_closing_chunk_of_another_series_does_not_close_a_label_set() { + let map_grid = grid(0, 60_000, 2); + let tree = NativeTree::try_new(map( + evaluated_input(Arc::clone(&map_grid)), + vec![MathOp::Abs], + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&map_grid)); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + + // `a` opens the label set and has not closed it when `b` closes an empty series onto it, and the + // chunk that closes `a` itself comes last. + let chunks = [ + evaluated_chunk_of(&schema, named_metric("a", "x"), 0, vec![Some(-1.0)], false), + evaluated_chunk_of(&schema, named_metric("b", "x"), 0, vec![], true), + evaluated_chunk_of(&schema, named_metric("a", "x"), 1, vec![Some(-2.0)], true), + ]; + let steps = chunks.len(); + let mut handed = Vec::new(); + for (step, chunk) in chunks.into_iter().enumerate() { + execution.push_evaluated(chunk).unwrap(); + while let Some(chunk) = execution + .next_chunk() + .unwrap_or_else(|error| panic!("step {step} was refused: {error}")) + { + handed.push(chunk); + } + } + assert_eq!( + handed.len(), + steps, + "the map did not consume all {steps} chunks it was given: {:?}", + decided(&handed) + ); + let last = handed.last().expect("the map handed nothing on"); + assert!(last.is_last, "the final output does not close its series"); + // `abs` drops the metric name, so the label set the output closes is `{job="x"}`. + let mut mapped = named_metric("a", "x"); + mapped.remove("__name__"); + assert_eq!( + last.series.metric, mapped, + "the final output closes a label set other than the one `abs` leaves of `a{{job=\"x\"}}`" + ); + assert_eq!( + (series_start(last), sample_values(last)), + (1, vec![Some(2.0)]), + "the value `a` held at position 1 did not survive" + ); + assert!( + execution.finish().unwrap().is_empty(), + "the map still held a chunk when the input ended" + ); +} + +/// Two series that each produce no value at all are not a collision: both answer empty, and the +/// node hands nothing on rather than refusing the second one. +/// +/// `rate` over a five-minute window with one sample in it has no result, in the single-point and +/// the batch kernel alike. Both series then arrive as a closing chunk of missing values under the +/// same label set -- `rate` drops the name -- but neither holds a sample, so there is nothing to +/// report twice and nothing to merge. +#[test] +fn two_series_with_no_result_do_not_collide() { + let out = grid(0, 60_000, 1); + let tree = NativeTree::try_new(window_with( + input(), + RangeFunction::Rate, + 300_000, + None, + Arc::clone(&out), + )) + .unwrap(); + let schema = raw_schema(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + + // One sample each: rate over a 5m window with a single sample has no result. + for name in ["a", "b"] { + execution + .push_raw(raw_chunk_of( + &schema, + name.as_bytes(), + MetricName::try_new(Some(name.into()), vec![]).unwrap(), + &[0], + &[1.0], + true, + )) + .unwrap(); + } + let mut handed = Vec::new(); + loop { + match execution.next_chunk() { + Ok(Some(chunk)) => handed.push(decided(&[chunk])), + Ok(None) => break, + Err(error) => panic!("two empty series were refused: {error}"), + } + } + assert!( + handed.iter().flatten().all(|(_, value)| value.is_none()), + "a series with no result handed a value on: {handed:?}" + ); +} + +/// A chunk of `metric` whose rows are the `spans` of `schema`'s grid, with `values` row by row. +/// +/// This is what a producer that decided only some positions hands over: the rows are absolute grid +/// positions and need not be adjacent, so a consumer that reads them as `start + row` names +/// positions the chunk does not hold. +fn sparse_chunk( + schema: &SeriesSchema, + metric: MetricName, + spans: &[(usize, usize)], + values: Vec, + is_last: bool, +) -> EvaluatedChunk { + let samples = Samples::from_float(Float64Array::from(values)); + let time = TimeAxis::from_grid_spans( + Arc::clone(schema.eval_grid.as_ref().unwrap()), + spans + .iter() + .map(|&(start, len)| GridSpan::new(start, len)) + .collect(), + ) + .unwrap(); + let series = Series { + metric, + time, + fields: SeriesFields::try_from_vec(vec![samples]).unwrap(), + }; + EvaluatedChunk::try_new(series, is_last).unwrap() +} + +#[test] +fn a_sparse_chunk_is_read_at_the_positions_it_holds_through_window_map_window() { + // An inner series that decided positions 0, 1 and 5, 6 of an eight-point grid and left the + // positions between them empty: two runs, not six rows with holes. + let inner_grid = grid(0, 60_000, 8); + let middle_grid = grid(0, 60_000, 8); + let outer_grid = grid(0, 120_000, 4); + let tree = NativeTree::try_new(window_with( + map( + window_with( + evaluated_input(Arc::clone(&inner_grid)), + RangeFunction::LastOverTime, + // One step exactly, so the window is the identity: each point reads the sample at + // its own position and nothing else. + 60_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&middle_grid), + ), + vec![MathOp::Abs], + ), + RangeFunction::LastOverTime, + 240_000, + Some(Arc::clone(&middle_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + let chunk = sparse_chunk( + &schema, + metric(), + &[(0, 2), (5, 2)], + vec![1.0, 2.0, 3.0, 4.0], + true, + ); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution.push_evaluated(chunk).unwrap(); + let out = run(&mut execution); + + // The outer window's points: every one of them has a sample in its range, so every one holds + // the last value its window saw. 4.0 sits at 360 s, inside the window that ends there, which + // is why the sparse tail is not lost between the runs. + assert_eq!( + decided(&out), + vec![ + (0, Some(1.0)), + (1, Some(2.0)), + (2, Some(2.0)), + (3, Some(4.0)), + ] + ); + assert_eq!(out.iter().filter(|chunk| chunk.is_last).count(), 1); +} + +#[test] +fn a_sparse_chunk_occupies_only_the_positions_it_holds() { + let map_grid = grid(0, 60_000, 8); + let tree = NativeTree::try_new(map( + evaluated_input(Arc::clone(&map_grid)), + vec![MathOp::Abs], + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&map_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + // Series a holds positions 0, 1 and 5, 6: two runs with a hole in the middle, not one run + // covering the hole. + execution + .push_evaluated(sparse_chunk( + &schema, + named_metric("a", "x"), + &[(0, 2), (5, 2)], + vec![-1.0, -2.0, -3.0, -4.0], + false, + )) + .unwrap(); + execution + .next_chunk() + .unwrap() + .expect("the first open chunk is handed on as it arrives"); + + // Position 2 sits in the hole, so series b occupies it and collides with nothing a holds. + execution + .push_evaluated(sparse_chunk( + &schema, + named_metric("b", "x"), + &[(2, 1)], + vec![-5.0], + true, + )) + .unwrap(); + execution + .next_chunk() + .unwrap_or_else(|error| panic!("the series in the hole was refused: {error}")) + .expect("the second series is handed on"); +} + +/// What an operation that empties every position hands on: nothing while the series is open, and +/// one empty chunk when it closes. +/// +/// The two are not interchangeable. A non-closing empty chunk is rejected by a downstream evaluator +/// (an empty delivery inside a series decides nothing), and dropping the closing one would leave +/// the consumer unable to tell "nothing more here" from "no more input", so the series would never +/// close and `finish` would report an input that ended mid-series. +#[test] +fn an_emptying_operation_hands_on_nothing_until_it_closes_the_series() { + let inner_grid = grid(0, 60_000, 4); + let outer_grid = grid(0, 120_000, 2); + // `clamp` with `max < min` empties every field of every chunk it sees. + let emptying = vec![MathOp::Clamp { min: 1.0, max: 0.0 }]; + let tree = NativeTree::try_new(window_with( + map(evaluated_input(Arc::clone(&inner_grid)), emptying.clone()), + RangeFunction::LastOverTime, + 120_000, + Some(Arc::clone(&inner_grid)), + Arc::clone(&outer_grid), + )) + .unwrap(); + let schema = evaluated_schema(Arc::clone(&inner_grid)); + + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + // An open chunk that maps to nothing yields nothing at all: it is not handed to the window, + // which would refuse an empty delivery inside a series. + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![5.0, 6.0], false)) + .unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "an emptying map handed on a chunk for a series still open" + ); + + // The closing chunk still closes: one empty chunk reaches the window and closes the series. + execution + .push_evaluated(evaluated_chunk(&schema, 2, vec![7.0, 8.0], true)) + .unwrap(); + let mut out = Vec::new(); + while let Some(chunk) = execution.next_chunk().unwrap() { + out.push(chunk); + } + out.extend(execution.finish().expect("the window closed its series")); + + assert_eq!( + out.len(), + 1, + "an emptying series handed on more than the one chunk that closes it" + ); + assert!( + out.iter().all(|chunk| chunk.series.time.is_empty()), + "the outer window produced a value for a series that held none" + ); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "the series closed a number of times other than once" + ); + assert!(out.last().unwrap().is_last); +} + +/// A tree with no window reports an input that ended mid-series as a truncated input. +/// +/// A window's evaluator is what knows a series has not closed: it holds the series until the chunk +/// carrying `is_last`, so closing the execution over what is left reports the input that never +/// delivered it. A tree of an input alone, or of a map over one, has no evaluator at all, so +/// without the input node saying so the series would reach the consumer open and never close. The +/// input node is the only place that sees every chunk the caller queued, so the truncation is +/// reported against it, for a tree of one node and for one with a map above it alike. +#[test] +fn an_input_that_ends_mid_series_is_reported_without_a_window() { + let map_grid = grid(0, 60_000, 2); + let schema = evaluated_schema(Arc::clone(&map_grid)); + let shapes = [ + // The input alone, and the same input under a map: neither has a window above it. + ("an input", evaluated_input(Arc::clone(&map_grid))), + ( + "a map over an input", + map(evaluated_input(Arc::clone(&map_grid)), vec![MathOp::Abs]), + ), + ]; + for (what, root) in shapes { + let tree = NativeTree::try_new(root).unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + // A chunk that does not close its series: the chunk carrying `is_last` never arrives. + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![-1.0, -2.0], false)) + .unwrap(); + let handed = execution + .next_chunk() + .unwrap() + .expect("a chunk of a series still open is handed on as it arrives"); + assert!( + !handed.is_last, + "{what} handed on a chunk that closed a series it never closed" + ); + let error = execution + .finish() + .expect_err("an input that ended mid-series was accepted"); + assert!( + error + .to_string() + .contains("ended before the last chunk of a series"), + "{what} reported {error} instead of a truncated input" + ); + } +} + +/// A window over native histograms reserves what it retains, payload included. +/// +/// A chunk is estimated from the samples it holds rather than from one float per field and row, so a +/// histogram field -- which holds no float at all -- reaches the figure as the table its indices point +/// into. A window over such a series therefore reserves for the payload it keeps, and a one-sample +/// histogram chunk is worth more than a one-sample float chunk of the same shape. +#[test] +fn a_window_over_histograms_reserves_what_it_retains() { + let out = grid(0, 10_000, 2); + let tree = NativeTree::try_new(window_with( + input(), + RangeFunction::LastOverTime, + 20_000, + None, + Arc::clone(&out), + )) + .unwrap(); + let schema = raw_schema(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + + for (timestamp, is_last) in [(0, false), (10_000, true)] { + execution + .push_raw(raw_histogram_chunk(&schema, &[timestamp], 2, is_last)) + .unwrap(); + while execution.next_chunk().unwrap().is_some() {} + assert_eq!( + execution.state().reserved_bytes(), + pool.reserved(), + "the state and the pool disagree on what the window holds" + ); + assert!( + pool.reserved() > 0, + "a window over a histogram series reserves for nothing" + ); + } + + // The table the indices point into is what the field costs, so the same one-sample chunk is + // worth more as a histogram than as a float. + let histogram = raw_histogram_chunk(&schema, &[0], 2, true); + let float = raw_chunk(&schema, &[0], &[1.0], true); + assert!( + raw_bytes(&histogram) > raw_bytes(&float), + "a histogram chunk is estimated at {} bytes, no more than the {} bytes of a float chunk of \ + the same shape", + raw_bytes(&histogram), + raw_bytes(&float), + ); +} + +/// A source reserves the estimate of every queued chunk, and gives each one back as it leaves. +/// +/// The figure is symmetric: what a chunk added when it entered is what comes out when it leaves, so a +/// queue that empties returns its reservation to the pool exactly. Two chunks of one series may share +/// one immutable histogram table, and each of them is charged for it: the estimate is per chunk, which +/// costs a bounded over-count of a shared table rather than a pass over the queue per operation. +#[test] +fn a_source_charges_its_queue_and_gives_it_back() { + let pool = pool(); + let mut source = source_runtime(&pool); + let schema = raw_schema(); + let mut builder = HistogramBatchBuilder::new(); + let index = push_histogram(&mut builder, 2); + let table = builder.finish().unwrap(); + let first = raw_shared_histogram_chunk(&schema, &table, index, 0, false); + let second = raw_shared_histogram_chunk(&schema, &table, index, 10_000, true); + let (first_bytes, second_bytes) = (raw_bytes(&first), raw_bytes(&second)); + + source.enqueue(NativeChunk::Raw(first)).unwrap(); + assert_eq!(source.buffer.reservation.size(), first_bytes); + assert_eq!(pool.reserved(), first_bytes); + + source.enqueue(NativeChunk::Raw(second)).unwrap(); + assert_eq!( + source.buffer.reservation.size(), + first_bytes + second_bytes, + "the queue is not the sum of the estimates it holds" + ); + assert_eq!(pool.reserved(), first_bytes + second_bytes); + + let popped = source + .pop_source() + .unwrap() + .expect("the queue held two chunks"); + assert!(!popped.is_last()); + // One table, two queued chunks, two shares of its estimate: what leaves is what the chunk that + // left was charged, and the other chunk's share stays. + assert_eq!( + source.buffer.reservation.size(), + second_bytes, + "the chunk that left did not give its estimate back" + ); + assert_eq!(pool.reserved(), second_bytes); + + source + .pop_source() + .unwrap() + .expect("the queue held two chunks"); + assert_eq!(source.buffer.reservation.size(), 0); + assert_eq!( + pool.reserved(), + 0, + "the queue emptied, but the pool holds its bytes" + ); + assert!(source.pop_source().unwrap().is_none()); +} + +/// Releasing a node drops its queue and returns the reservation once. +/// +/// A node stopped with chunks still queued holds nothing afterwards: the queue goes, and the bytes it +/// was reserved for go back to the pool rather than staying held for as long as the caller keeps the +/// stopped executor alive. +#[test] +fn releasing_a_node_drops_its_queue() { + let pool = pool(); + let mut source = source_runtime(&pool); + let schema = raw_schema(); + let mut builder = HistogramBatchBuilder::new(); + let index = push_histogram(&mut builder, 2); + let table = builder.finish().unwrap(); + for timestamp in [0, 10_000] { + source + .enqueue(NativeChunk::Raw(raw_shared_histogram_chunk( + &schema, &table, index, timestamp, false, + ))) + .unwrap(); + } + assert!(pool.reserved() > 0); + + source.release(); + assert!(source.queued.is_empty(), "the queue outlived its release"); + assert_eq!(source.buffer.reservation.size(), 0); + assert_eq!(pool.reserved(), 0, "the reservation was not returned"); + assert!(source.pop_source().unwrap().is_none()); +} + +/// A chunk whose axis and fields disagree is refused, not read past the end of a field. +/// +/// [`Series`] keeps its fields public, so a chunk built by struct literal -- by a caller of this +/// crate as much as by a mistake here -- can carry fewer samples than the axis places rows. The +/// label-set ledger reads every row the axis names, so it is where such a chunk would be read out of +/// bounds; it reports the misalignment against the node that was handed it instead. +#[test] +fn a_series_whose_axis_and_fields_disagree_is_rejected() { + let grid = grid(0, 60_000, 4); + // Two samples placed on a four-row axis: the third row the axis names has nothing to read. + let samples = Samples::from_float(Float64Array::from(vec![1.0, 2.0])); + let time = TimeAxis::Grid(GridRange::try_new(Arc::clone(&grid), 0, 4).unwrap()); + let series = Series { + metric: named_metric("requests", "job"), + time, + fields: SeriesFields::try_from_vec(vec![samples]).unwrap(), + }; + let chunk = EvaluatedChunk::try_new(series, false).expect("the axis is addressed on a grid"); + + let mut emitted = Emitted::default(); + let error = emitted + .occupy(&NodePath::root(), &chunk) + .expect_err("a field with fewer samples than the axis places rows was accepted"); + let message = error.to_string(); + assert!( + message.contains("misaligned") + && message.contains("4 rows") + && message.contains("2 samples"), + "the rejection does not name what the axis places against what the field holds: {message}" + ); +} + +/// A sparse series that only ever moves forward is appended to its history, not merged into it again +/// chunk after chunk. +/// +/// One legal series arrives as eight chunks of one position each, every position a hole apart from +/// the last: no label set is reached twice, no position of a field is occupied twice, and this is +/// what a scan produces as its positions advance. The stored runs still grow by one per chunk, so +/// rebuilding the array after each chunk moves `K(K+1)/2` runs to hold `K` rows of real data, which +/// is the cost this asserts away. +/// +/// The chunks go through [`super::Emitted::occupy`], the helper the runtime records an output with, +/// so what is checked is the history that helper keeps. Three shapes are pushed: chunks landing past +/// every stored run, which have to be appended to; a chunk starting where the last stored run ends, +/// which has to be joined to it rather than left beside it; and chunks landing before what is +/// stored, which cannot be appended to and have to go through the full merge while still leaving the +/// history sorted and disjoint. +#[test] +fn a_sparse_series_that_moves_forward_is_appended_to_its_history() { + let map_grid = grid(0, 60_000, 21); + let schema = evaluated_schema(Arc::clone(&map_grid)); + let path = NodePath::root(); + let mut emitted = Emitted::default(); + + // One row at `position`, which is one run of one position: the same series every time, since a + // map dropping the name leaves them all one label set. + let push = |emitted: &mut Emitted, position: usize| { + let chunk = sparse_chunk( + &schema, + named_metric("a", "x"), + &[(position, 1)], + vec![-1.0], + false, + ); + emitted + .occupy(&path, &chunk) + .unwrap_or_else(|error| panic!("position {position} was refused: {error}")); + }; + // The runs stored under the one label set, which is what `absorb` keeps in position order. + let stored = |emitted: &Emitted| -> Vec<(usize, usize)> { + emitted + .sets + .values() + .next() + .expect("the chunk was recorded under one label set") + .fields + .first() + .expect("the series has one field") + .iter() + .map(|run| (run.start, run.end)) + .collect() + }; + let ordered = |runs: &[(usize, usize)]| { + runs.iter().all(|(start, end)| start < end) + && runs.windows(2).all(|pair| pair[0].1 < pair[1].0) + }; + + // Eight chunks, one position each, a hole between them. + let positions = [4, 6, 8, 10, 12, 14, 16, 18]; + let before = absorb_rebuilds(); + for position in positions { + push(&mut emitted, position); + } + assert_eq!( + stored(&emitted), + vec![ + (4, 5), + (6, 7), + (8, 9), + (10, 11), + (12, 13), + (14, 15), + (16, 17), + (18, 19), + ], + "a sparse series was not stored as the one run each of its chunks occupies" + ); + assert_eq!( + absorb_rebuilds() - before, + 0, + "the {} chunks of a series moving forward rebuilt its history instead of appending to it", + positions.len() + ); + + // A chunk that starts where the last stored run ends touches it, and the two are one run: the + // fast path appends, and the boundary is the part it has to join. + let before = absorb_rebuilds(); + push(&mut emitted, 19); + assert_eq!( + stored(&emitted), + vec![ + (4, 5), + (6, 7), + (8, 9), + (10, 11), + (12, 13), + (14, 15), + (16, 17), + (18, 20), + ], + "a chunk touching the last stored run was left beside it rather than joined to it" + ); + assert_eq!( + absorb_rebuilds() - before, + 0, + "a chunk touching the last stored run was merged rather than appended to it" + ); + + // A chunk landing in a hole before the last stored run cannot be appended to: it joins the runs + // on both sides of it, so it goes through the full merge, which has to join them all the same. + let before = absorb_rebuilds(); + push(&mut emitted, 5); + assert_eq!( + stored(&emitted), + vec![ + (4, 7), + (8, 9), + (10, 11), + (12, 13), + (14, 15), + (16, 17), + (18, 20), + ], + "a chunk in a hole did not join the runs on either side of it" + ); + assert_eq!( + absorb_rebuilds() - before, + 1, + "a chunk landing before the last stored run was appended rather than merged" + ); + + // A chunk before every stored run is the other shape the fast path cannot take. + let before = absorb_rebuilds(); + push(&mut emitted, 1); + let runs = stored(&emitted); + assert_eq!( + runs, + vec![ + (1, 2), + (4, 7), + (8, 9), + (10, 11), + (12, 13), + (14, 15), + (16, 17), + (18, 20), + ], + "a chunk before every stored run did not land in front of them" + ); + assert_eq!( + absorb_rebuilds() - before, + 1, + "a chunk landing before every stored run was appended rather than merged" + ); + assert!( + ordered(&runs), + "the merged history is not sorted, disjoint and joined: {runs:?}" + ); +} diff --git a/src/promql/src/native/mod.rs b/src/promql/src/native/mod.rs index e843c0169be..c9545a9fe69 100644 --- a/src/promql/src/native/mod.rs +++ b/src/promql/src/native/mod.rs @@ -29,5 +29,6 @@ pub mod batch; mod display; +pub mod execute; pub mod metrics; pub mod plan; diff --git a/src/promql/src/native/plan.rs b/src/promql/src/native/plan.rs index 6cca42134f3..f5715b21f72 100644 --- a/src/promql/src/native/plan.rs +++ b/src/promql/src/native/plan.rs @@ -122,8 +122,8 @@ pub struct AggregateStateSchema { /// A scan or exchange boundary bound as a node input. /// /// The binding records what the input was created for: the output a consumer type-checks against, -/// and the ordering it guarantees. A window reads the ordering, rejecting an interleaved input; no -/// other consumer reads it yet. +/// and the ordering it guarantees. An instant selection and a window both read the ordering, +/// rejecting an interleaved input; no other consumer reads it yet. #[derive(Clone, Debug)] pub struct BoundInput { /// What the input produces: raw samples from a scan, or state from an exchange boundary. @@ -329,14 +329,13 @@ impl NativeNode { ))); } } + ensure_not_interleaved(input)?; ensure_samples(&self.output, "an instant selection")?; // A selection is one source row per grid point: `select_instants` picks row // indices and every field is taken at them, so it carries one value per input // field. ensure_same_fields(&input.output, &self.output, "an instant selection")?; ensure_grid(&self.output, &op.output_grid, "an instant selection")?; - // `select_instants` reads one series at a time and holds no cross-series state, so - // an interleaved input is enough for it: no ordering check here. } NativeExpr::Window { input, op } => { input.validate()?; @@ -354,17 +353,7 @@ impl NativeNode { "a window reads an empty range: its range must be positive", )); } - // A range evaluator consumes one series to its last chunk before it starts the - // next, so interleaved chunks of different series cannot be fed to it. Only an - // input node carries an ordering, so only there is there one to check. - if let NativeExpr::Input(binding) = &input.expr - && binding.ordering == SeriesOrdering::Interleaved - { - return Err(invalid( - "a window reads one series to its last chunk before the next, so \ - interleaved chunks cannot be fed to it", - )); - } + ensure_not_interleaved(input)?; // What is checked here and just below is that the declared input grid equals the // input's grid, and that the output grid matches the declared one. The grid is // not checked to cover the window's read range @@ -624,6 +613,25 @@ fn ensure_grid(ty: &NativeType, expected: &Arc, what: &str) -> Result< } } +/// Checks that `input`, when it is a [`NativeExpr::Input`], does not declare interleaved series. +/// +/// A native evaluation reads one series to its last chunk before the next: a range evaluator folds +/// a series completely before it starts another, and an instant selection's consumer collects one +/// series' chunks into one buffer. Neither can be handed chunks of different series that +/// interleave, which is the one rule both consumers state. Only an input node carries an ordering, +/// so only there is there one to check. +fn ensure_not_interleaved(input: &NativeNode) -> Result<()> { + if let NativeExpr::Input(binding) = &input.expr + && binding.ordering == SeriesOrdering::Interleaved + { + return Err(invalid( + "a native evaluation reads one series to its last chunk before the next, so \ + interleaved chunks cannot be fed to it", + )); + } + Ok(()) +} + fn invalid(reason: &str) -> DataFusionError { // A rejected tree is a compiler defect, not a query error: the compiler only builds a node // after deciding that this combination is supported. `Internal` keeps that meaning on the wire diff --git a/src/promql/src/native/plan/tests.rs b/src/promql/src/native/plan/tests.rs index 1c42c3b32af..ba01eb53cdd 100644 --- a/src/promql/src/native/plan/tests.rs +++ b/src/promql/src/native/plan/tests.rs @@ -96,6 +96,18 @@ fn input_with_fields(count: usize) -> NativeNode { } } +/// A scan of raw samples whose chunks of different series may interleave. +fn interleaved_input() -> NativeNode { + let output = raw(); + NativeNode { + expr: NativeExpr::Input(BoundInput { + output: output.clone(), + ordering: SeriesOrdering::Interleaved, + }), + output, + } +} + /// A constant scalar, which is a scalar type evaluated on the statement's grid. fn scalar_on(value: f64, grid: Arc) -> NativeNode { NativeNode { @@ -457,15 +469,35 @@ fn a_window_checks_its_declared_input_grid() { /// chunks of different series may not interleave. #[test] fn a_window_cannot_read_interleaved_input() { - let output = raw(); - let interleaved = NativeNode { - expr: NativeExpr::Input(BoundInput { - output: output.clone(), - ordering: SeriesOrdering::Interleaved, - }), - output, + let error = window_over(interleaved_input(), None) + .validate() + .unwrap_err(); + assert!( + error + .to_string() + .contains("interleaved chunks cannot be fed to it"), + "{error}" + ); +} + +/// An instant selection's consumer collects one series' chunks into one buffer, so it reads a +/// series to its last chunk before the next: it refuses an interleaved input for the reason a +/// window does. +#[test] +fn an_instant_selection_cannot_read_interleaved_input() { + let out = grid(0, 60_000, 4); + let node = NativeNode { + expr: NativeExpr::Instant { + input: Box::new(interleaved_input()), + op: BoundInstant { + lookback_ms: 300_000, + offset_ms: 0, + output_grid: Arc::clone(&out), + }, + }, + output: evaluated(Arc::new([]), out), }; - let error = window_over(interleaved, None).validate().unwrap_err(); + let error = node.validate().unwrap_err(); assert!( error .to_string() diff --git a/src/promql/src/range_eval.rs b/src/promql/src/range_eval.rs index 5d7d8e0b0f2..3a7e2384809 100644 --- a/src/promql/src/range_eval.rs +++ b/src/promql/src/range_eval.rs @@ -81,7 +81,8 @@ pub(crate) struct FloatWindows<'a> { pub resets: &'a [CounterReset], /// Absolute position of `timestamps[0]`: a reset position is `base` plus a sample index. pub base: u64, - /// One range per window, ascending and left-open at the start. + /// One range per window, ascending. Each is a half-open index range `[start, end)`; the time + /// window it was derived from is left-open, `(T - range_ms, T]`. pub windows: &'a [Range], /// The evaluation timestamp of each window. pub eval_ts: &'a [i64], @@ -91,8 +92,9 @@ pub(crate) struct FloatWindows<'a> { impl FloatWindows<'_> { /// The resets inside window `index`. /// - /// `windows` are absolute indices into `timestamps`, so a window whose start has already - /// been released still maps to the right reset. + /// `windows` index the retained arrays as they stand in this batch, not absolutely: each + /// span is relative to `timestamps` and `values`, which is why `base` plus a span start is + /// the absolute position of the sample the span starts at. pub(crate) fn resets_of(&self, index: usize) -> &[CounterReset] { let span = &self.windows[index]; resets_between( @@ -168,6 +170,10 @@ struct PendingBatch { /// Evaluation timestamp of each accumulated point. eval_ts: Vec, /// The retained-sample range of each point's window, one list per field. + /// + /// A span is relative to the retained arrays at the time it was recorded, not an absolute + /// index, so [`FieldSamples::advance`] invalidates it: that is why `evaluate` flushes the + /// batch before advancing. spans: Vec>>, /// One output column per field, written by the batch kernel. columns: Vec>>, @@ -191,6 +197,16 @@ impl PendingBatch { self.eval_ts.is_empty() } + /// The bytes this batch's own vectors hold, by capacity. + fn bytes(&self) -> usize { + let spans: usize = self.spans.iter().map(|spans| spans.capacity()).sum(); + let columns: usize = self.columns.iter().map(|column| column.capacity()).sum(); + self.indices.capacity() * std::mem::size_of::() + + self.eval_ts.capacity() * std::mem::size_of::() + + spans * std::mem::size_of::>() + + columns * std::mem::size_of::>() + } + /// Drops the accumulated points, keeping the allocations. fn clear_points(&mut self) { self.indices.clear(); @@ -203,9 +219,6 @@ impl PendingBatch { /// Records one point for the next batch call: its evaluation timestamp and, per field, the range /// of retained samples its window covers. -/// -/// The ranges are relative to the retained samples, so samples dropped before the batch flushes -/// would shift them; [`flush_batch`] therefore runs before any `advance`. fn accumulate(pending: &mut PendingBatch, index: usize, eval_ts: i64, fields: &[FieldSamples]) { debug_assert_eq!(pending.spans.len(), fields.len()); pending.indices.push(index); @@ -237,8 +250,6 @@ fn flush_batch( *batch_calls += 1; } for (index, field) in fields.iter().enumerate() { - // `pending.spans` index the retained arrays absolutely, so no adjustment is needed for - // samples `advance` has released: those sit before every span start. let batch = FloatWindows { timestamps: &field.timestamps, values: &field.values, @@ -277,6 +288,29 @@ struct SeriesState { emitted_end: usize, } +impl SeriesState { + /// The bytes this series' identity holds, which the rows handed out do not cover. + fn identity_bytes(&self) -> usize { + // A scan slices the primary key out of a decoded buffer, so the bytes the key shows are not + // the bytes it keeps: a key of a few bytes pins the allocation it was sliced out of. + // Charging the slice length instead would show a handful of bytes for an allocation the + // size of a decoded batch. One evaluator holds one series at a time, and an evaluated input + // holds no key at all, so there is no second slice of the same allocation to dedupe. + let primary_key = self + .primary_key + .as_ref() + .map_or(0, |key| key.capacity().max(key.len())); + let metric: usize = self.metric.name().map_or(0, |name| name.len()) + + self + .metric + .tags() + .iter() + .map(|tag| tag.name.len() + tag.value.len()) + .sum::(); + std::mem::size_of::() + primary_key + metric + } +} + /// How the windows of an `@`-anchored selector map to the grid. /// /// Like Prometheus, an anchored selector is read once, as the window of the first grid point @@ -299,7 +333,17 @@ struct FieldSamples { head: usize, /// Native histogram samples, kept apart from the floats as Prometheus does. histograms: Vec<(i64, FloatHistogram)>, + /// Histograms before `histogram_head` are behind every remaining window; their payloads are + /// released by [`advance_histograms`](Self::advance_histograms). histogram_head: usize, + /// Estimated payload of the histograms this field still holds: the bucket lists and other + /// buffers the samples own, which no `size_of` of a slot shows. + /// + /// Measured for a histogram when it is admitted and released with it, so a retained sample is + /// not walked again on every push. A histogram is never modified once admitted, so the payload + /// its release subtracts is the one its admission added. Samples already behind every window + /// keep their payload in the figure until the drain releases them. + histogram_bytes: usize, /// Sequence number of `values[0]`. Absolute and monotonic across the series this evaluator /// evaluates, so a reset keeps its position when `head` samples are dropped instead of every /// index being renumbered. An empty field holds the number of samples already dropped, which @@ -335,6 +379,16 @@ fn resets_between(resets: &[CounterReset], start: u64, end: u64) -> &[CounterRes } impl FieldSamples { + /// The bytes this field's buffers hold, by capacity: the samples, the history behind `head` + /// included, plus the estimated payload of the histograms still held. + fn bytes(&self) -> usize { + self.timestamps.capacity() * std::mem::size_of::() + + self.values.capacity() * std::mem::size_of::() + + self.histograms.capacity() * std::mem::size_of::<(i64, FloatHistogram)>() + + self.resets.capacity() * std::mem::size_of::() + + self.histogram_bytes + } + fn new(index_resets: bool) -> Self { Self { index_resets, @@ -408,6 +462,23 @@ impl FieldSamples { self.resets.drain(..dropped); } + /// Drops the histograms before `histogram_head`, releasing the payload they held. + /// + /// The dropped samples are measured as they go, which costs one measurement per dropped + /// histogram in total: the retained ones are never scanned again. + fn advance_histograms(&mut self) { + let head = std::mem::take(&mut self.histogram_head); + if head == 0 { + return; + } + let dropped: usize = self.histograms[..head] + .iter() + .map(|(_, histogram)| histogram.heap_bytes()) + .sum(); + self.histogram_bytes = self.histogram_bytes.saturating_sub(dropped); + self.histograms.drain(..head); + } + /// The resets inside the window holding `len` samples from `head`. fn resets_in(&self, len: usize) -> &[CounterReset] { let start = self.base + self.head as u64; @@ -415,6 +486,24 @@ impl FieldSamples { } } +/// The bytes the histograms a row holds box, beyond the slots they sit in. +/// +/// A slot is an `Option`: a pointer and a tag. A result that is a native histogram +/// points at a [`FloatHistogram`] on the heap, which owns its bucket lists in turn, and both stay +/// alive between pushes. Only slots the last evaluated point left in place are counted: a push +/// clears the row. +fn boxed_bytes(row: &[Option]) -> usize { + row.iter() + .flatten() + .map(|sample| match sample { + GridSample::Histogram(histogram) => { + std::mem::size_of::() + histogram.heap_bytes() + } + GridSample::Float(_) => 0, + }) + .sum() +} + impl RangeEvaluator { /// Creates an evaluator of `function` for the series of `input` onto `grid`. pub fn try_new( @@ -535,6 +624,32 @@ impl RangeEvaluator { self.batch_calls } + /// An estimate of the bytes this evaluator keeps between pushes. + /// + /// Counted by capacity, so it is what the storage holds rather than what it uses: the retained + /// samples per field (timestamps, values, the reset index and the native histograms with the + /// payload each of them owns), the row a push fills with the histograms it boxes, the identity + /// of the open series and the pending batch's own vectors. Left out are the kernel's scratch + /// buffers, this struct's `Vec` headers and the grid, which every chunk addressed on it shares. + /// + /// The figure is an estimate, not a ledger of every allocation: a histogram's payload is + /// measured once when it is admitted and released with it, so neither the retained samples nor + /// the transient allocations of a push are walked again. + /// + /// A push is the only caller that also holds the incoming chunk, and it charges that itself for + /// as long as the call runs. The figure must not be per-chunk either: charging every chunk + /// consumed would make the charge grow with the chunk count, so a fixed window over a long + /// series would read as an allocation failure even though the window holds no more samples. + pub(crate) fn retained_bytes(&self) -> usize { + let fields: usize = self.fields.iter().map(FieldSamples::bytes).sum(); + let row = self.row.capacity() * std::mem::size_of::>(); + fields + + row + + boxed_bytes(&self.row) + + self.series.as_ref().map_or(0, SeriesState::identity_bytes) + + self.pending.bytes() + } + /// Schema of the evaluated series: the input fields on the evaluation grid. pub fn output_schema(&self) -> &Arc { &self.output_schema @@ -914,6 +1029,8 @@ impl RangeEvaluator { if !raw && h.reset_hint != CounterResetHint::Gauge { h.reset_hint = CounterResetHint::Unknown; } + field.histogram_bytes = + field.histogram_bytes.saturating_add(h.heap_bytes()); field.histograms.push((shifted, h)); } } @@ -1044,8 +1161,6 @@ impl RangeEvaluator { } state.cursor += 1; } - // The accumulated spans index the retained arrays, which `advance` below rewrites, so the - // batch has to flush first. if let Some(kernel) = self.batch.as_mut() { flush_batch( kernel, @@ -1074,6 +1189,11 @@ impl RangeEvaluator { } state.cursor = decided; } + debug_assert!( + self.pending.is_empty(), + "`evaluate` flushes the batch before `advance` rewrites the retained arrays a \ + pending span indexes" + ); for field in &mut self.fields { // After the last window, samples are still ingested so that the overflow and // histogram checks do not depend on how the series is chunked, but none are kept. @@ -1083,8 +1203,7 @@ impl RangeEvaluator { field.histogram_head = field.histograms.len(); } field.advance(); - field.histograms.drain(..field.histogram_head); - field.histogram_head = 0; + field.advance_histograms(); } Ok(()) } diff --git a/src/promql/src/range_eval/function.rs b/src/promql/src/range_eval/function.rs index 7e49247834b..55f14264aa7 100644 --- a/src/promql/src/range_eval/function.rs +++ b/src/promql/src/range_eval/function.rs @@ -73,6 +73,11 @@ impl RangeFunction { /// the same as saying `IS_COUNTER` in `extrapolated_rate`: `rate` and `increase`. A kernel /// that corrects counters without the index being maintained would silently drop every /// correction, so a new counter function has to be added here as well. + /// + /// It governs both readers of the index: the per-window kernel above, and the batch kernel, + /// which reads [`FloatWindows::resets_of`]. When it returns `false`, `resets` stays empty and + /// `resets_of` yields nothing, so a batch kernel for a counter function would skip every + /// correction and report `delta`-like values. pub(crate) fn indexes_counter_resets(self) -> bool { matches!(self, Self::Rate | Self::Increase) } diff --git a/src/promql/src/range_eval/tests.rs b/src/promql/src/range_eval/tests.rs index c11af5e3c02..1e5e6488a71 100644 --- a/src/promql/src/range_eval/tests.rs +++ b/src/promql/src/range_eval/tests.rs @@ -721,6 +721,185 @@ fn functions_match_range_udfs() { } } +/// Replaces the float sample of `field` at every row index in `rows` with a native histogram +/// sample, so the windows holding one take the mixed-window path. +fn mix_in_histograms(chunks: &mut [RawSeriesChunk], field: usize, rows: &BTreeSet) { + let histogram = "0/0/0/4014000000000000/4014000000000000/1/0_1/4014000000000000///"; + let view = OwnedHistogram::parse(histogram); + let mut start = 0; + for piece in chunks { + let len = piece.time.len(); + let local: Vec = rows + .range(start..start + len) + .map(|&row| row - start) + .collect(); + start += len; + if local.is_empty() { + continue; + } + let mut builder = HistogramBatchBuilder::new(); + let indices: UInt32Array = (0..len) + .map(|row| { + local + .binary_search(&row) + .is_ok() + .then(|| builder.push(view.view()).unwrap()) + }) + .collect(); + // A position is a float or a histogram, never both, so the float sample of a mixed-in row + // goes. + let values: Float64Array = piece.fields.as_slice()[field] + .iter() + .enumerate() + .map(|(row, sample)| match sample { + _ if local.binary_search(&row).is_ok() => None, + SampleRef::Float(value) => Some(value), + SampleRef::Missing => None, + SampleRef::Histogram(_) => unreachable!("histograms are mixed in once"), + }) + .collect(); + let histograms = HistogramSamples::try_new(indices, builder.finish().unwrap()).unwrap(); + let mixed = Samples::try_from_mixed(values, histograms).unwrap(); + let fields = piece + .fields + .as_slice() + .iter() + .enumerate() + .map(|(index, samples)| { + if index == field { + mixed.clone() + } else { + samples.clone() + } + }) + .collect(); + piece.fields = SeriesFields::try_from_vec(fields).unwrap(); + } +} + +/// Asserts two evaluator results are identical, including native histogram samples, which the +/// dense form [`expand`] rejects. +fn assert_same_series(actual: Option<&Series>, expected: Option<&Series>, context: &str) { + match (actual, expected) { + (None, None) => {} + (Some(actual), Some(expected)) => { + assert_eq!(actual.metric, expected.metric, "{context}: metric"); + let (TimeAxis::Grid(actual_range), TimeAxis::Grid(expected_range)) = + (&actual.time, &expected.time) + else { + panic!( + "{context}: expected grid series, got {:?} and {:?}", + actual.time, expected.time + ); + }; + assert_eq!(actual_range, expected_range, "{context}: grid range"); + for (field, (got, want)) in actual + .fields + .as_slice() + .iter() + .zip(expected.fields.as_slice()) + .enumerate() + { + for (row, (got, want)) in got.iter().zip(want.iter()).enumerate() { + let context = format!("{context}: field {field} row {row}"); + match (got, want) { + (SampleRef::Missing, SampleRef::Missing) => {} + (SampleRef::Float(got), SampleRef::Float(want)) => { + assert_eq!(got.to_bits(), want.to_bits(), "{context}: {got} vs {want}") + } + (SampleRef::Histogram(got), SampleRef::Histogram(want)) => { + let got = FloatHistogram::try_from_view(&got).unwrap(); + let want = FloatHistogram::try_from_view(&want).unwrap(); + // Both paths run the same histogram code over the same samples, so + // this is an exact comparison: an epsilon of zero. + assert_histogram_close(&got, &want, 0.0, &context); + } + (SampleRef::Missing, _) => { + panic!("{context}: only the per-window path produced a sample") + } + (_, SampleRef::Missing) => { + panic!("{context}: only the batch path produced a sample") + } + _ => panic!("{context}: a float on one path and a histogram on the other"), + } + } + } + } + (actual, expected) => panic!( + "{context}: one path produced a series, the other none: {actual:?} vs {expected:?}" + ), + } +} + +/// `rate` and `increase` over random input, computed twice: by the batch kernel and by the +/// per-window path. +/// +/// Both evaluators are built by `try_new` and differ in nothing but the dispatch shape, and both +/// kernels reach `extrapolated_rate_of`, so the results have to be bit-identical. A difference +/// means the batch read a different set of samples, evaluated at a different time, or corrected a +/// different reset: a window bound that disagrees with the per-window path. +/// +/// The rows mix gaps, stale markers, counter resets and native histogram samples, the last so +/// that some grid points take the mixed-window fallback, where a pending batch has to flush +/// before the point is decided on the per-window path. The random chunk splits are what make +/// `advance` drop samples mid-series, so spans that outlived one would read shifted samples. +#[test] +fn batch_counter_functions_match_the_per_window_path_on_random_input() { + let mut batch_calls = 0; + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let fields = rng.random_range(1..=3); + let (grid, mut window) = random_window(&mut rng); + // An anchored selector reads one window and installs no batch kernel, so both paths would + // run the same code; every seed stays on the grid-driven batch path. + window.anchor_ms = None; + let function = if rng.random_bool(0.5) { + RangeFunction::Rate + } else { + RangeFunction::Increase + }; + let mut batched = + RangeEvaluator::try_new(&raw_schema(fields), grid.clone(), window, function).unwrap(); + let mut per_window = + RangeEvaluator::try_new(&raw_schema(fields), grid.clone(), window, function).unwrap(); + // The per-window path is the same evaluator without the batch kernel. `with_kernel`, + // which the other differential tests use, rejects native histogram samples, and these + // series carry some, so removing the batch form is the only switch that leaves the rest + // of the evaluation identical. + per_window.batch = None; + // One evaluator serves several series, so state must not leak between them. + for host in 0..3 { + let rows = random_rows(&mut rng, fields); + let histogram_field = rng.random_range(0..fields); + let histogram_rows: BTreeSet = + (0..rows.len()).filter(|_| rng.random_bool(0.1)).collect(); + let mut chunks = random_chunks(&mut rng, &rows, fields, host); + mix_in_histograms(&mut chunks, histogram_field, &histogram_rows); + // A push hands out the positions it decided, not only on the last chunk, so what each + // path produced is every chunk it handed out, folded back into one series. + let batched_chunks = collect_chunks(&mut batched, &chunks); + let per_window_chunks = collect_chunks(&mut per_window, &chunks); + + let batched_result = fold_chunks(&batched_chunks, batched.output_schema()); + let per_window_result = fold_chunks(&per_window_chunks, per_window.output_schema()); + + let context = format!("seed {seed} host {host}"); + assert_same_series( + batched_result.as_ref(), + per_window_result.as_ref(), + &context, + ); + } + batch_calls += batched.batch_call_count(); + } + // The comparison is worth nothing unless the batch kernel ran at all: counting the calls is + // what tells a batch-path comparison from one that silently fell back to per-window. + assert!( + batch_calls > 300, + "only {batch_calls} batch kernel calls: the batch path was barely reached" + ); +} + /// A chunk of entirely present float columns is appended in bulk, and that is the path the test /// asserts: the per-sample loop produces the same output, so comparing values alone could not /// tell whether the bulk path ran. @@ -786,28 +965,194 @@ fn a_chunk_with_histograms_is_not_appended_in_bulk() { assert_eq!(evaluator.bulk_ingest_count(), 0); } -/// `rate` over a float-only series takes the batch path. The per-window path produces the same -/// output, so a test comparing values alone could not tell which one ran. +/// One raw chunk of one field holding a single native histogram sample at `ts`, whose positive side +/// is one span of `buckets` buckets of one count each. +fn histogram_chunk(schema: &SeriesSchema, buckets: i32, ts: i64) -> RawSeriesChunk { + let offsets = [0]; + let lengths = [buckets]; + let counts = vec![1.0; usize::try_from(buckets).unwrap()]; + let mut builder = HistogramBatchBuilder::new(); + let index = builder + .push(HistogramView { + schema: 0, + zero_threshold: 0.0, + zero_count: 0.0, + count: f64::from(buckets), + sum: f64::from(buckets), + reset_hint: CounterResetHint::Unknown, + start_timestamp: None, + custom_values: &[], + positive: BucketSideView { + span_offsets: &offsets, + span_lengths: &lengths, + buckets: &counts, + }, + negative: BucketSideView { + span_offsets: &[], + span_lengths: &[], + buckets: &[], + }, + }) + .unwrap(); + let samples = Samples::try_from_histogram( + UInt32Array::from(vec![Some(index)]), + builder.finish().unwrap(), + ) + .unwrap(); + RawSeriesChunk::try_new( + schema, + Buffer::from(0u64.to_le_bytes().to_vec()), + Some(metric(0)), + RawTimestamps::try_new(ScalarBuffer::from(vec![ts])).unwrap(), + SeriesFields::try_from_vec(vec![samples]).unwrap(), + false, + ) + .unwrap() +} + +/// A window retaining a native histogram is charged for the payload it owns, not only the slot. +/// +/// The histogram sample below stays ahead of the second grid point's window, so the evaluator keeps +/// it, and the two runs differ in nothing but the number of buckets on the positive side: 4 and +/// 4096. Those buckets are `(index, count)` pairs on the heap, which no `size_of` of the struct +/// shows -- about 64 KiB for the wider one. The property is that this payload dominates the +/// estimate, not that it is counted to the byte: the wider histogram alone has to be reported as at +/// least half the physical width of its buckets, and the narrow one has to stay far below that. A +/// figure counting slots alone would report one number for both. #[test] -fn rate_over_floats_is_evaluated_in_batches() { - let rows: Vec = (0..20) - .map(|index| (index as i64 * 10_000, vec![Some(index as f64)])) - .collect(); +fn a_retained_histogram_is_charged_for_its_buckets() { + let retained = |buckets: i32| -> usize { + let schema = raw_schema(1); + let mut evaluator = RangeEvaluator::try_new( + &schema, + grid(0, 10_000, 2), + // Far longer than the grid: the second point's window still reaches the sample, so the + // push retains it instead of advancing past it. + RangeWindow { + range_ms: 600_000, + offset_ms: 0, + anchor_ms: None, + }, + // A function that counts the histogram, so what the row holds is a float: the two + // figures then differ in the payload of the retained sample alone, not in what the row + // boxes beside it. + RangeFunction::CountOverTime, + ) + .unwrap(); + evaluator + .push(&histogram_chunk(&schema, buckets, 0)) + .unwrap(); + evaluator.retained_bytes() + }; + let (few, many) = (4, 4096); + let physical = usize::try_from(many).unwrap() * std::mem::size_of::<(i32, f64)>(); + let (narrow, wide) = (retained(few), retained(many)); + assert!( + wide >= physical / 2, + "a window retaining one histogram of {many} buckets reports {wide} bytes, not at least half \ + the {physical} bytes its buckets occupy", + ); + assert!( + narrow < wide / 2, + "a window retaining one histogram of {few} buckets reports {narrow} bytes, not far below \ + the {wide} bytes it reports for {many} buckets: the bucket payload is not what the \ + estimate follows", + ); +} + +/// The payload a retained histogram is charged for is released with the sample. +/// +/// The histogram below is most of what the evaluator holds, and it stays inside the second grid +/// point's window: a push that only reaches the first point retains it. The chunk at 30 s is beyond +/// the window of every later point, so the evaluation that push triggers drops the histogram while +/// the series is still open. The estimate must come back down by its payload: one that charges at +/// admission and never releases it would keep reporting the history of every histogram the series +/// carried. The closing chunk then drains what is left and ends the series, so the estimate falls +/// back to the field and row buffers' capacity, with no series identity and no histogram payload. +#[test] +fn an_evicted_histogram_releases_its_payload() { + let schema = raw_schema(1); + let buckets = 4096; + let payload = usize::try_from(buckets).unwrap() * std::mem::size_of::<(i32, f64)>(); let mut evaluator = RangeEvaluator::try_new( - &raw_schema(1), - grid(0, 10_000, 20), + &schema, + grid(0, 10_000, 3), + // 20 s of window over 10 s of step: the histogram at 0 is inside the window of the second + // point but outside the third's, so the second push evicts it. RangeWindow { - range_ms: 30_000, + range_ms: 20_000, offset_ms: 0, anchor_ms: None, }, - RangeFunction::Rate, + RangeFunction::CountOverTime, ) .unwrap(); - evaluator.push(&chunk(&rows, 1, 0, true)).unwrap(); + evaluator + .push(&histogram_chunk(&schema, buckets, 0)) + .unwrap(); + let retained = evaluator.retained_bytes(); assert!( - evaluator.batch_call_count() > 0, - "rate over floats must reach the batch kernel" + retained >= payload, + "a window retaining one histogram of {buckets} buckets reports {retained} bytes, less than \ + the {payload} bytes of buckets it holds", + ); + + // The float chunk keeps the series open, so only the window drops the histogram. + evaluator + .push(&chunk(&[(30_000, vec![Some(1.0)])], 1, 0, false)) + .unwrap(); + let evicted = evaluator.retained_bytes(); + assert!( + retained.saturating_sub(evicted) >= payload / 2, + "dropping one histogram of {payload} bytes left the estimate at {evicted} bytes, down from \ + {retained}: the payload it owned is still counted", + ); + + evaluator + .push(&chunk(&[(40_000, vec![Some(2.0)])], 1, 0, true)) + .unwrap(); + let closed = evaluator.retained_bytes(); + assert!( + closed < payload / 2, + "a closed series still reports {closed} bytes, of which the {payload} bytes of bucket \ + payload were released with their samples", + ); +} + +/// A fixed-size window over a long series of histograms reports a fixed size. +/// +/// Each push adds one histogram and the next point's window drops the one before it, so the +/// payload charged at admission has to come back at eviction. A counter that only grew would make +/// the estimate follow the length of the series instead of the window over it. +#[test] +fn a_fixed_window_over_histograms_keeps_the_estimate_bounded() { + let peak_retained = |chunks: i64| -> usize { + let schema = raw_schema(1); + let mut evaluator = RangeEvaluator::try_new( + &schema, + grid(0, 1, 1024), + // One millisecond of window over a one millisecond step: the next point evicts the + // sample of this one. + RangeWindow { + range_ms: 1, + offset_ms: 0, + anchor_ms: None, + }, + RangeFunction::CountOverTime, + ) + .unwrap(); + let mut peak = 0; + for ts in 0..chunks { + // Left open: the window, not the close, is what releases the sample. + evaluator.push(&histogram_chunk(&schema, 64, ts)).unwrap(); + peak = peak.max(evaluator.retained_bytes()); + } + peak + }; + assert_eq!( + peak_retained(8), + peak_retained(64), + "a fixed-size window over a series of histograms grew with the length of the series" ); } @@ -881,7 +1226,6 @@ fn a_batch_spanning_fields_and_the_flush_threshold_matches_the_per_window_path() .push(&chunk(&rows, fields, 0, true)) .unwrap() .expect("the series completes"); - assert_eq!(per_window.batch_call_count(), 0); // Fold the chunk back into one series first: a chunk's range reports the positions its push // decided, which can include leading and trailing positions holding no value, while the @@ -899,8 +1243,9 @@ fn a_batch_spanning_fields_and_the_flush_threshold_matches_the_per_window_path() /// A window holding a native histogram sample keeps its grid point on the per-window path, since /// the batch kernel reads the floats alone and cannot reproduce the mixed-window rules. /// -/// The series is laid out so every grid point's window contains the histogram: one point fewer -/// than the first test, so no point can fall before it. +/// The series is laid out so every grid point's window contains the histogram: three samples +/// 10 s apart with the histogram on the first, and a window reaching 30 s back, so a window +/// covering any grid point reaches timestamp 0. #[test] fn a_histogram_window_keeps_rate_on_the_per_window_path() { let histogram = "0/0/0/4014000000000000/4014000000000000/1/0_1/4014000000000000///"; diff --git a/src/promql/src/source.rs b/src/promql/src/source.rs index acd06605f19..03845419344 100644 --- a/src/promql/src/source.rs +++ b/src/promql/src/source.rs @@ -23,6 +23,7 @@ use std::task::{Context, Poll}; use common_error::ext::BoxedError; use datatypes::error::InvalidSeriesSnafu; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::raw::RawSeriesChunk; use datatypes::series::samples::SeriesSchema; use futures::Stream; @@ -31,17 +32,20 @@ use store_api::region_engine::{RawLabelSource, RawSeriesStream, SendableRawSerie pub use merge::merge_ordered_raw_series; -/// Materializes complete metric labels from a scan-bound raw-series source. +/// Materializes complete metric labels from a raw-series frame stream. /// -/// The caller supplies an already-bound source. Adjacent-chunk pack/unpack validation remains -/// the caller's responsibility, and nonadjacent key recurrence is governed by the producer -/// contract. Explicit labels are retained as supplied; their completeness cannot be inferred -/// from the schema. No authorization or cross-stream completion is performed here. +/// The labels are decoded from the stream's own label decode description, so a consumer that holds +/// only the frame needs no scan-bound binding; a stream that carries complete labels already, and +/// so declares no description, needs no decoder and is forwarded as supplied. Adjacent-chunk +/// pack/unpack validation remains the caller's responsibility, and nonadjacent key recurrence is +/// governed by the producer contract. Explicit labels are retained as supplied; their completeness +/// cannot be inferred from the schema. No authorization or cross-stream completion is performed +/// here. pub fn materialize_raw_labels( source: SendableRawSeriesStream, ) -> Result { let schema = Arc::clone(source.schema()); - let label_source = source.label_source().clone(); + let decode = source.label_decode_spec().cloned(); let ordering = source.ordering(); if schema.eval_grid.is_some() { return Err(BoxedError::new( @@ -59,12 +63,12 @@ pub fn materialize_raw_labels( .build(), )); } - let decoder = match &label_source { - RawLabelSource::PrimaryKey { .. } => Some( - PrimaryKeyLabelDecoder::new(&label_source, LabelProjection::All) + let decoder = match &decode { + Some(description) => Some( + PrimaryKeyLabelDecoder::from_decode_spec(description, LabelProjection::All) .map_err(BoxedError::new)?, ), - RawLabelSource::Explicit => None, + None => None, }; Ok(Box::pin(MaterializedRawSeriesStream { @@ -125,6 +129,12 @@ impl RawSeriesStream for MaterializedRawSeriesStream { &self.label_source } + /// Returns `None`: materialized labels are already complete, so this stream declares no label + /// decode description and a consumer needs no decoder. + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + None + } + fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -150,10 +160,18 @@ impl MaterializedRawSeriesStream { } } (None, Some(metric)) => Some(metric), - _ => { + (Some(_), Some(_)) => { return Err(BoxedError::new( InvalidSeriesSnafu { - reason: "raw chunk label mode does not match source", + reason: "raw chunk already carries labels but the source describes a key", + } + .build(), + )); + } + (None, None) => { + return Err(BoxedError::new( + InvalidSeriesSnafu { + reason: "raw chunk carries no labels and the source describes no key", } .build(), )); diff --git a/src/promql/src/source/merge.rs b/src/promql/src/source/merge.rs index a734d63c51b..13f19145ea7 100644 --- a/src/promql/src/source/merge.rs +++ b/src/promql/src/source/merge.rs @@ -23,17 +23,25 @@ use std::task::{Context, Poll}; use common_error::ext::BoxedError; use datatypes::error::InvalidSeriesSnafu; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::raw::RawSeriesChunk; use datatypes::series::samples::{SeriesFields, SeriesSchema}; use futures::Stream; use mito_codec::labels::{LabelProjection, PrimaryKeyLabelDecoder}; -use store_api::region_engine::{RawLabelSource, RawSeriesStream, SendableRawSeriesStream}; +use store_api::region_engine::{ + NO_BOUND_LABEL_SOURCE, RawLabelSource, RawSeriesStream, SendableRawSeriesStream, +}; /// Merge compatible, explicitly supplied raw streams by complete read key and time. /// /// Callers provide authorized bindings and own snapshot/storage policy. This does not select an /// MVCC view, resolve overlapping physical copies, or roll back emitted prefixes after errors. /// `is_last` is global only over these inputs; there is no fallback, replay, or input sorting. +/// +/// The merge contract is the label layout, not a binding: every input must declare the same one — +/// from its frame if it arrived as a frame, otherwise from its own binding — and the merged stream +/// advertises that one declaration. A consumer therefore decodes the merged stream from the frame; +/// the identity it reports is none, see [`RawSeriesStream::label_source`]. pub fn merge_ordered_raw_series( inputs: Vec, ) -> Result { @@ -42,7 +50,7 @@ pub fn merge_ordered_raw_series( .next() .ok_or_else(|| invalid("raw merge requires at least one input"))?; let schema = Arc::clone(first.schema()); - let binding = first.label_source().clone(); + let decode = merged_decode_spec(first.as_ref().get_ref())?; let mut streams = vec![Input::new(first)]; for stream in inputs { streams.push(Input::new(stream)); @@ -52,13 +60,13 @@ pub fn merge_ordered_raw_series( .stream .as_ref() .ok_or_else(|| invalid("raw merge input disappeared"))?; - validate_binding(stream.label_source())?; - validate_input(stream.as_ref().get_ref(), &schema, &binding)?; + validate_input(stream.as_ref().get_ref(), &schema)?; + validate_decode(stream.as_ref().get_ref(), &decode)?; } Ok(Box::pin(OrderedRawMerge { streams, schema, - binding, + decode, candidate: None, })) } @@ -67,17 +75,50 @@ fn invalid(reason: &'static str) -> BoxedError { BoxedError::new(InvalidSeriesSnafu { reason }.build()) } -fn validate_binding(binding: &RawLabelSource) -> Result<(), BoxedError> { - PrimaryKeyLabelDecoder::new(binding, LabelProjection::All) - .map(|_| ()) - .map_err(BoxedError::new) +/// Returns the label layout a stream declares, with no binding required to state it. +/// +/// Every key-encoded stream declares its own description — a producer derives it from the binding it +/// read under and states it before the first poll, and a stream reassembled from a frame carries the +/// description its producer wrote — so the answer is exactly what decoding that stream's chunks +/// needs. A merge can therefore compare inputs that were framed and inputs that were not in the same +/// terms. `None` means the stream carries complete labels and declares no key layout at all. +fn declared_layout(stream: &dyn RawSeriesStream) -> Option { + stream.label_decode_spec().cloned() } -fn validate_input( +/// Returns the one label decode description the merged stream advertises for all inputs. +/// +/// The first input's declaration is forwarded unchanged, so a merge advertises exactly what its +/// inputs advertise, whether they are frame-decoded or straight from a producer. The declaration is +/// validated as a decode layout before it is advertised, so a downstream consumer holding only the +/// merged frame schema can decode it. +fn merged_decode_spec(stream: &dyn RawSeriesStream) -> Result { + let description = declared_layout(stream).ok_or_else(|| { + invalid("raw merge requires inputs that describe a primary-key label layout") + })?; + PrimaryKeyLabelDecoder::from_decode_spec(&description, LabelProjection::All) + .map_err(BoxedError::new)?; + Ok(description) +} + +/// Requires every input to declare the advertised label layout. +/// +/// The merged stream advertises one description, so an input declaring another would be decoded as +/// if it had the advertised encoding. Every input is compared through [`declared_layout`], which +/// reads the input's own declaration, so no input needs a binding to be present. +fn validate_decode( stream: &dyn RawSeriesStream, - schema: &SeriesSchema, - binding: &RawLabelSource, + decode: &LabelDecodeSpec, ) -> Result<(), BoxedError> { + if declared_layout(stream).as_ref() != Some(decode) { + return Err(invalid( + "raw merge inputs describe different label decode layouts", + )); + } + Ok(()) +} + +fn validate_input(stream: &dyn RawSeriesStream, schema: &SeriesSchema) -> Result<(), BoxedError> { let other = stream.schema(); if stream.ordering() != SeriesOrdering::KeyThenTime || other.eval_grid.is_some() @@ -88,58 +129,14 @@ fn validate_input( .iter() .zip(schema.fields.iter()) .any(|(a, b)| a.name != b.name) - || !bindings_compatible(stream.label_source(), binding) { return Err(invalid( - "raw merge requires equal raw schemas, primary-key bindings, and ReadKeyThenTime ordering", + "raw merge requires equal raw schemas and ReadKeyThenTime ordering", )); } Ok(()) } -fn bindings_compatible(left: &RawLabelSource, right: &RawLabelSource) -> bool { - let ( - RawLabelSource::PrimaryKey { - metric_name: lm, - physical_metadata: lmeta, - logical_tags: lt, - }, - RawLabelSource::PrimaryKey { - metric_name: rm, - physical_metadata: rmeta, - logical_tags: rt, - }, - ) = (left, right) - else { - return false; - }; - if lm != rm - || lt.len() != rt.len() - || lt.iter().any(|tag| { - !rt.iter() - .any(|other| tag.column_id == other.column_id && tag.name == other.name) - }) - { - return false; - } - let lm = lmeta.as_ref(); - let rm = rmeta.as_ref(); - lm.primary_key == rm.primary_key - && lm.primary_key_encoding == rm.primary_key_encoding - && lm - .primary_key - .iter() - .all(|id| match (lm.column_by_id(*id), rm.column_by_id(*id)) { - (Some(a), Some(b)) => { - a.column_id == b.column_id - && a.column_schema.name == b.column_schema.name - && a.column_schema.data_type == b.column_schema.data_type - && a.semantic_type == b.semantic_type - } - _ => false, - }) -} - struct Input { stream: Option, head: Option, @@ -223,7 +220,8 @@ impl Input { struct OrderedRawMerge { streams: Vec, schema: Arc, - binding: RawLabelSource, + /// The description the merged stream advertises for all inputs; see [`merged_decode_spec`]. + decode: LabelDecodeSpec, candidate: Option, } @@ -441,8 +439,19 @@ impl RawSeriesStream for OrderedRawMerge { fn schema(&self) -> &Arc { &self.schema } + /// Returns [`NO_BOUND_LABEL_SOURCE`] unconditionally. + /// + /// A merge is not a region scan, so it has no scan-bound identity to report, and it does not + /// invent one from its inputs. Its decode contract is [`Self::label_decode_spec`]; identity is + /// not something a merged stream can report. fn label_source(&self) -> &RawLabelSource { - &self.binding + &NO_BOUND_LABEL_SOURCE + } + + /// Returns the description this merge advertises for every input, so a consumer of the merged + /// stream decodes labels from the frame instead of from the binding. + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + Some(&self.decode) } fn ordering(&self) -> SeriesOrdering { SeriesOrdering::KeyThenTime diff --git a/src/promql/src/source/merge/tests.rs b/src/promql/src/source/merge/tests.rs index 853da99e823..f7729b31ba2 100644 --- a/src/promql/src/source/merge/tests.rs +++ b/src/promql/src/source/merge/tests.rs @@ -23,12 +23,15 @@ use std::task::{Context, Poll, Waker}; use api::v1::SemanticType; use common_error::ext::{BoxedError, ErrorExt, PlainError}; use common_error::status_code::StatusCode; -use common_query::series_frame::{pack_raw_series, raw_series_frame_ordering, unpack_raw_series}; +use common_query::series_frame::{ + label_decode_spec, pack_raw_series, raw_series_frame_ordering, unpack_raw_series, +}; use datatypes::arrow::array::Float64Array; use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; use datatypes::data_type::ConcreteDataType; use datatypes::prelude::Value; use datatypes::schema::ColumnSchema; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{FieldSpec, SampleRef, Samples, SeriesFields, SeriesSchema}; use datatypes::series::time::{EvalGrid, GridSpec}; @@ -162,6 +165,8 @@ fn chunk(key: Buffer, times: &[i64], values: &[f64], is_last: bool) -> RawSeries struct TestStream { schema: Arc, labels: RawLabelSource, + /// The description this input declares; `None` means its chunks carry complete labels. + decode: Option, ordering: SeriesOrdering, items: VecDeque>>>, // Delivered once the gate opens, so a blocked body can resume with later chunks. @@ -204,6 +209,9 @@ impl RawSeriesStream for TestStream { fn label_source(&self) -> &RawLabelSource { &self.labels } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.decode.as_ref() + } fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -228,6 +236,9 @@ fn stream_with( ( Box::pin(TestStream { schema, + // Derived once from the binding this fixture stores, as a producer does; an + // explicit-label fixture declares none. + decode: label_decode_spec(&labels), labels, ordering, items: items.into_iter().collect(), @@ -253,6 +264,7 @@ fn gated_stream( ( Box::pin(TestStream { schema: schema(), + decode: label_decode_spec(&labels), labels, ordering: SeriesOrdering::KeyThenTime, items: [ready(first)].into(), @@ -280,6 +292,7 @@ fn gated_resuming_stream( ( Box::pin(TestStream { schema: schema(), + decode: label_decode_spec(&labels), labels, ordering: SeriesOrdering::KeyThenTime, items: [ready(first)].into(), @@ -326,6 +339,7 @@ fn gated_pending_stream( ( Box::pin(TestStream { schema: schema(), + decode: label_decode_spec(&labels), labels, ordering: SeriesOrdering::KeyThenTime, items: VecDeque::new(), @@ -376,10 +390,7 @@ async fn merges_full_encoded_keys_and_interleaved_chunks_with_global_finality() let mut merged = merge_ordered_raw_series(vec![left, right]).unwrap(); assert_eq!(merged.ordering(), SeriesOrdering::KeyThenTime); assert!(merged.schema().eval_grid.is_none()); - assert!(matches!( - merged.label_source(), - RawLabelSource::PrimaryKey { .. } - )); + assert!(matches!(merged.label_source(), RawLabelSource::Explicit)); let mut observed = Vec::new(); while let Some(item) = merged.next().await { let item = item.unwrap(); @@ -446,11 +457,10 @@ async fn merges_two_unpacked_frames_through_the_frame_codec() { chunks.into_iter().map(ready), ); let schema = Arc::clone(stream.schema()); - let binding = stream.label_source().clone(); let packed = pack_raw_series(stream).unwrap(); let ordering = raw_series_frame_ordering(packed.schema().as_ref()).unwrap(); assert_eq!(SeriesOrdering::KeyThenTime, ordering); - unpack_raw_series(packed, schema, binding, ordering).unwrap() + unpack_raw_series(packed, Some(schema)).unwrap() }; let left = framed( label_source(1), @@ -1219,193 +1229,97 @@ async fn greater_key_head_allows_finished_series_before_future_eof() { assert_eq!(right_drops.load(Ordering::SeqCst), 1); } +/// The merge contract is the label layout, which is exactly what a frame can carry: any difference a +/// frame could state — the metric name, the encoding, the key columns with their physical names, +/// types, and order, and the logical tag mapping — is a conflict and is rejected before any input is +/// polled. #[tokio::test] -async fn preflight_rejects_incompatible_primary_key_bindings_without_polling() { - let full_baseline = label_source(1); - let physical_comparison_baseline = { +async fn preflight_rejects_incompatible_label_layouts_without_polling() { + let sensor = Arc::<[BoundTag]>::from([BoundTag { + column_id: 9, + name: Arc::from("sensor"), + }]); + let rack = Arc::<[BoundTag]>::from([BoundTag { + column_id: 8, + name: Arc::from("rack"), + }]); + let edited = |edit: &dyn Fn(&mut store_api::metadata::RegionMetadata), + logical_tags: Arc<[BoundTag]>| { let RawLabelSource::PrimaryKey { metric_name, physical_metadata, .. - } = label_source(1) + } = label_source(2) else { unreachable!() }; + let mut changed = (*physical_metadata).clone(); + edit(&mut changed); RawLabelSource::PrimaryKey { metric_name, - physical_metadata, - logical_tags: Arc::from([BoundTag { - column_id: 9, - name: Arc::from("sensor"), - }]), + physical_metadata: Arc::new(changed), + logical_tags, } }; let cases = [ ( "metric name", label_source_with(2, PrimaryKeyEncoding::Dense, "other_metric"), - "binding", ), ( "encoding", label_source_with(2, PrimaryKeyEncoding::Sparse, "room_temperature"), - "binding", ), ( - "primary key column ids", - { - let RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - logical_tags, - } = label_source(2) - else { - unreachable!() - }; - let mut changed = (*physical_metadata).clone(); - changed.primary_key = vec![9, 8]; - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata: Arc::new(changed), - logical_tags, - } - }, - "binding", + "primary key column order", + edited( + &|metadata| metadata.primary_key = vec![9, 8], + sensor.clone(), + ), ), ( - "primary key physical name mismatch", - { - let RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - .. - } = label_source(2) - else { - unreachable!() - }; - let mut changed = (*physical_metadata).clone(); - changed - .column_metadatas - .iter_mut() - .find(|column| column.column_id == 8) - .unwrap() - .column_schema - .name = "shelf".to_string(); - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata: Arc::new(changed), - logical_tags: Arc::from([BoundTag { - column_id: 9, - name: Arc::from("sensor"), - }]), - } - }, - "merge compatibility", - ), - ( - "primary key semantic role", - { - let RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - .. - } = label_source(2) - else { - unreachable!() - }; - let mut changed = (*physical_metadata).clone(); - changed - .column_metadatas - .iter_mut() - .find(|column| column.column_id == 8) - .unwrap() - .semantic_type = SemanticType::Field; - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata: Arc::new(changed), - logical_tags: Arc::from([BoundTag { - column_id: 9, - name: Arc::from("sensor"), - }]), - } - }, - "merge compatibility", + "primary key physical name", + edited( + &|metadata| { + metadata + .column_metadatas + .iter_mut() + .find(|column| column.column_id == 8) + .unwrap() + .column_schema + .name = "shelf".to_string(); + }, + sensor.clone(), + ), ), ( "primary key physical type", - { - let RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - logical_tags, - } = label_source(2) - else { - unreachable!() - }; - let mut changed = (*physical_metadata).clone(); - changed - .column_metadatas - .iter_mut() - .find(|column| column.column_id == 8) - .unwrap() - .column_schema - .data_type = ConcreteDataType::int64_datatype(); - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata: Arc::new(changed), - logical_tags, - } - }, - "binding", - ), - ( - "logical tag mapping", - { - let RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - .. - } = label_source(2) - else { - unreachable!() - }; - RawLabelSource::PrimaryKey { - metric_name, - physical_metadata, - logical_tags: Arc::from([BoundTag { - column_id: 8, - name: Arc::from("rack"), - }]), - } - }, - "binding", + edited( + &|metadata| { + metadata + .column_metadatas + .iter_mut() + .find(|column| column.column_id == 8) + .unwrap() + .column_schema + .data_type = ConcreteDataType::int64_datatype(); + }, + rack.clone(), + ), ), + ("logical tag mapping", edited(&|_| {}, rack)), ]; - for (description, incompatible, reason) in cases { - let baseline = if reason == "merge compatibility" { - &physical_comparison_baseline - } else { - &full_baseline - }; - let (first, polls_a, drops_a) = - stream((*baseline).clone(), SeriesOrdering::KeyThenTime, []); + for (description, incompatible) in cases { + let (first, polls_a, drops_a) = stream(label_source(1), SeriesOrdering::KeyThenTime, []); let (second, polls_b, drops_b) = stream(incompatible, SeriesOrdering::KeyThenTime, []); let error = match merge_ordered_raw_series(vec![first, second]) { Ok(_) => panic!("{description} mismatch must be rejected"), Err(error) => error.to_string(), }; - if reason == "merge compatibility" { - assert_eq!( - error, - "Invalid series: raw merge requires equal raw schemas, primary-key bindings, and ReadKeyThenTime ordering", - "{description}" - ); - } else { - assert!( - error.to_lowercase().contains(reason), - "{description}: {error}" - ); - } + assert!( + error.to_lowercase().contains("decode layouts"), + "{description}: {error}" + ); assert_eq!(polls_a.load(Ordering::SeqCst), 0); assert_eq!(polls_b.load(Ordering::SeqCst), 0); assert_eq!(drops_a.load(Ordering::SeqCst), 1); @@ -1413,6 +1327,45 @@ async fn preflight_rejects_incompatible_primary_key_bindings_without_polling() { } } +/// A difference that lives only in local region metadata — here a primary-key column's semantic +/// role — is not a merge conflict: a frame cannot state it, and both sides still decode to the same +/// labels, so holding producer-side inputs to it would make them answerable to a stricter contract +/// than the wire form can express. +#[tokio::test] +async fn preflight_ignores_metadata_the_label_layout_cannot_express() { + let base = label_source(1); + let RawLabelSource::PrimaryKey { + metric_name, + physical_metadata, + logical_tags, + } = label_source(2) + else { + unreachable!() + }; + let mut changed = (*physical_metadata).clone(); + changed + .column_metadatas + .iter_mut() + .find(|column| column.column_id == 8) + .unwrap() + .semantic_type = SemanticType::Field; + let incompatible = RawLabelSource::PrimaryKey { + metric_name, + physical_metadata: Arc::new(changed), + logical_tags, + }; + + let (first, _, _) = stream(base, SeriesOrdering::KeyThenTime, []); + let (second, _, _) = stream(incompatible, SeriesOrdering::KeyThenTime, []); + let merged = merge_ordered_raw_series(vec![first, second]) + .expect("the same label layout merges whatever a local metadata snapshot says"); + assert_eq!( + merged.label_decode_spec(), + label_decode_spec(&label_source(1)).as_ref(), + "the merged stream advertises the layout both sides agree on" + ); +} + #[tokio::test] async fn metadata_snapshots_with_same_layout_are_accepted() { let base = label_source(1); @@ -1882,3 +1835,186 @@ async fn primary_key_codec_identity_survives_merge_then_label_materialization() } assert!(output.is_last); } + +/// The merged stream advertises one decode description, so an input that describes its own labels +/// differently is rejected instead of being decoded under the advertised layout. An input carrying +/// the same layout is accepted, which is what a merge over unpacked frames looks like. +#[tokio::test] +async fn merge_rejects_inputs_that_describe_different_decode_layouts() { + let carried = + label_decode_spec(&label_source(1)).expect("a primary-key binding describes its key"); + let mut different = carried.clone(); + different.metric_name = "other_metric".to_string(); + + let carried_stream = |decode: LabelDecodeSpec| { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + ( + Box::pin(TestStream { + schema: schema(), + labels: label_source(1), + decode: Some(decode), + ordering: SeriesOrdering::KeyThenTime, + items: VecDeque::new(), + deferred: VecDeque::new(), + polls: polls.clone(), + drops: drops.clone(), + gate: None, + release: None, + }) as SendableRawSeriesStream, + polls, + drops, + ) + }; + + let (first, _, _) = carried_stream(carried.clone()); + let (same_layout, _, _) = carried_stream(carried.clone()); + let merged = merge_ordered_raw_series(vec![first, same_layout]) + .expect("inputs that declare the same layout merge"); + assert_eq!(merged.label_decode_spec(), Some(&carried)); + + let (first, _, _) = carried_stream(carried.clone()); + let (mismatched, mismatched_polls, mismatched_drops) = carried_stream(different); + let error = match merge_ordered_raw_series(vec![first, mismatched]) { + Ok(_) => panic!("inputs describing different layouts must be rejected"), + Err(error) => error.to_string(), + }; + assert!(error.to_lowercase().contains("decode"), "{error}"); + assert_eq!(mismatched_polls.load(Ordering::SeqCst), 0); + assert_eq!(mismatched_drops.load(Ordering::SeqCst), 1); +} + +/// A merge whose inputs are frame-decoded has no identity to forward, and the merged stream says so +/// instead of inventing one: it reports [`NO_BOUND_LABEL_SOURCE`] while its description still states +/// that its chunks are undecoded key chunks, which is what a consumer decodes from. +#[tokio::test] +async fn merge_without_a_bound_identity_still_advertises_the_decode_layout() { + use common_query::series_frame::raw_series_frame_ordering; + use common_query::series_frame::{pack_raw_series, unpack_raw_series}; + use store_api::region_engine::NO_BOUND_LABEL_SOURCE; + + use crate::source::materialize_raw_labels; + + let carried = + label_decode_spec(&label_source(1)).expect("a primary-key binding describes its key"); + let key = read_key("rack-a", "sensor-1"); + let framed_input = |times: &[i64], values: &[f64]| { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + Box::pin(TestStream { + schema: schema(), + labels: NO_BOUND_LABEL_SOURCE, + decode: Some(carried.clone()), + ordering: SeriesOrdering::KeyThenTime, + items: VecDeque::from([Poll::Ready(Some(Ok(chunk( + key.clone(), + times, + values, + true, + ))))]), + deferred: VecDeque::new(), + polls, + drops, + gate: None, + release: None, + }) as SendableRawSeriesStream + }; + + let merged = merge_ordered_raw_series(vec![ + framed_input(&[10, 30], &[1.0, 3.0]), + framed_input(&[20, 40], &[2.0, 4.0]), + ]) + .expect("inputs that declare the same layout merge"); + + assert_eq!(merged.label_decode_spec(), Some(&carried)); + assert!( + matches!(merged.label_source(), RawLabelSource::Explicit), + "no identity was established, and none is invented for the merged stream: {:?}", + NO_BOUND_LABEL_SOURCE + ); + assert_eq!(merged.ordering(), SeriesOrdering::KeyThenTime); + + // Packing advertises that layout under the merge's ordering, so the frame states both. + let framed = pack_raw_series(merged).unwrap(); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(framed.schema().as_ref()).unwrap(), + Some(carried.clone()) + ); + assert_eq!( + raw_series_frame_ordering(framed.schema().as_ref()).unwrap(), + SeriesOrdering::KeyThenTime + ); + + // And it decodes: materialization reads the advertised description, not an identity. + let raw = unpack_raw_series(framed, Some(schema())).unwrap(); + let rows = materialize_raw_labels(raw) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + rows.iter() + .map(|row| row.time.timestamp(0).unwrap()) + .collect::>(), + [10, 20, 30, 40] + ); + let metric = rows[0].metric.clone().expect("labels were materialized"); + assert_eq!(metric.name(), Some("room_temperature")); + assert_eq!(metric.get("rack"), Some("rack-a")); + assert_eq!(metric.get("sensor"), Some("sensor-1")); +} + +/// A merged stream's label decode description survives the frame wire form: the packed schema +/// declares that description, so a consumer holding only the schema can rebuild a decoder and decode +/// the merged keys without a request-side label source. +#[tokio::test] +async fn merged_frame_declares_a_label_decode_description_that_decodes_its_keys() { + use common_query::series_frame::{pack_raw_series, unpack_raw_series}; + use datatypes::series::frame::raw_series_frame_label_decode; + use mito_codec::labels::{LabelProjection, PrimaryKeyLabelDecoder}; + + let key = read_key("rack-a", "sensor-1"); + let (left, _, _) = stream( + label_source(1), + SeriesOrdering::KeyThenTime, + [ready(chunk(key.clone(), &[10, 30], &[1.0, 3.0], true))], + ); + let (right, _, _) = stream( + label_source(2), + SeriesOrdering::KeyThenTime, + [ready(chunk(key.clone(), &[20, 40], &[2.0, 4.0], true))], + ); + let merged = merge_ordered_raw_series(vec![left, right]).unwrap(); + let expected = merged + .label_decode_spec() + .cloned() + .expect("the merge advertises a primary-key layout"); + let series_schema = merged.schema().clone(); + let binding = label_source(1); + let ordering = merged.ordering(); + let framed = pack_raw_series(merged).unwrap(); + assert_eq!( + common_query::series_frame::raw_series_frame_ordering(framed.schema().as_ref()).unwrap(), + ordering + ); + let declared = framed.schema(); + assert_eq!( + raw_series_frame_label_decode(declared.as_ref()).unwrap(), + Some(expected.clone()) + ); + + // The recovered description alone rebuilds an equivalent decoder; no metadata snapshot from + // the merge is consulted. + let mut from_schema = + PrimaryKeyLabelDecoder::from_decode_spec(&expected, LabelProjection::All).unwrap(); + let mut from_binding = PrimaryKeyLabelDecoder::new(&binding, LabelProjection::All).unwrap(); + assert_eq!( + from_schema.decode(&key).unwrap(), + from_binding.decode(&key).unwrap() + ); + + let mut decoded = unpack_raw_series(framed, Some(series_schema)).unwrap(); + let first = decoded.next().await.unwrap().unwrap(); + assert_eq!(first.primary_key.as_slice(), key.as_slice()); + assert!(first.metric.is_none(), "key labels stay undecoded"); +} diff --git a/src/promql/src/source/merge/tests/payload.rs b/src/promql/src/source/merge/tests/payload.rs index 2aff48a3c14..a180a01784a 100644 --- a/src/promql/src/source/merge/tests/payload.rs +++ b/src/promql/src/source/merge/tests/payload.rs @@ -18,10 +18,14 @@ use std::sync::{Arc, Weak}; use std::task::{Context, Poll}; use common_error::ext::BoxedError; -use common_query::series_frame::{pack_raw_series, unpack_raw_series}; +use common_query::series_frame::{ + ensure_frame_matches_label_source, label_decode_spec, pack_raw_series, + raw_series_frame_ordering, unpack_raw_series, +}; use datatypes::arrow::array::{Array, Float64Array, ListArray, UInt32Array}; use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::histogram::{ BucketSideView, CounterResetHint, HistogramBatch, HistogramBatchBuilder, HistogramView, }; @@ -167,6 +171,8 @@ fn payload_chunk( struct PayloadStream { schema: Arc, labels: RawLabelSource, + /// The description this fixture declares, derived once from `labels`, as a producer does. + decode: Option, items: VecDeque>, } @@ -184,6 +190,9 @@ impl RawSeriesStream for PayloadStream { fn label_source(&self) -> &RawLabelSource { &self.labels } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.decode.as_ref() + } fn ordering(&self) -> SeriesOrdering { SeriesOrdering::KeyThenTime } @@ -192,6 +201,7 @@ impl RawSeriesStream for PayloadStream { fn payload_stream(labels: RawLabelSource, chunks: Vec) -> SendableRawSeriesStream { Box::pin(PayloadStream { schema: payload_schema(), + decode: label_decode_spec(&labels), labels, items: chunks.into_iter().map(Ok).collect(), }) @@ -243,10 +253,26 @@ async fn merges_typed_multifield_payload_through_frame_and_label_roundtrip() { ); let merged = merge_ordered_raw_series(vec![a, b]).unwrap(); let frame_schema = merged.schema().clone(); - let frame_binding = merged.label_source().clone(); let frame_ordering = merged.ordering(); + assert!( + matches!(merged.label_source(), RawLabelSource::Explicit), + "a merge is not a region scan, so it reports no scan-bound identity" + ); let framed = pack_raw_series(merged).unwrap(); - let raw = unpack_raw_series(framed, frame_schema, frame_binding, frame_ordering).unwrap(); + // The frame states the layout the decoder uses: it matches the description an input's binding + // declares, since the merge only accepts inputs that declare one layout. + ensure_frame_matches_label_source(framed.schema().as_ref(), &label_source(1)).unwrap(); + assert_eq!( + raw_series_frame_ordering(framed.schema().as_ref()).unwrap(), + frame_ordering, + "the packed frame declares the merge's ordering" + ); + let raw = unpack_raw_series(framed, Some(frame_schema)).unwrap(); + assert_eq!( + raw.ordering(), + frame_ordering, + "unpacking derives the ordering from the frame" + ); let rows = materialize_raw_labels(raw) .unwrap() .try_collect::>() @@ -442,7 +468,8 @@ async fn merge_retains_sliced_float_time_and_histogram_owners_until_output_drop( ["temperature", "latency", "observations"] ); assert!( - matches!(merged.label_source(), RawLabelSource::PrimaryKey { metric_name, .. } if metric_name.as_ref() == "room_temperature") + matches!(merged.label_source(), RawLabelSource::Explicit), + "a merge is not a region scan, so it reports no scan-bound identity" ); assert_eq!(merged.ordering(), SeriesOrdering::KeyThenTime); let prefix = merged.next().await.unwrap().unwrap(); diff --git a/src/promql/src/source/tests.rs b/src/promql/src/source/tests.rs index af19e85f0e5..bf88ac382cd 100644 --- a/src/promql/src/source/tests.rs +++ b/src/promql/src/source/tests.rs @@ -21,11 +21,13 @@ use std::task::{Context, Poll}; use api::v1::SemanticType; use common_error::ext::{BoxedError, PlainError}; use common_error::status_code::StatusCode; +use common_query::series_frame::{label_decode_spec, pack_raw_series, unpack_raw_series}; use datatypes::arrow::array::{Array, Float64Array, UInt32Array}; use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; use datatypes::data_type::ConcreteDataType; use datatypes::prelude::Value; use datatypes::schema::ColumnSchema; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::histogram::{ BucketSideView, CounterResetHint, HistogramBatch, HistogramBatchBuilder, HistogramView, }; @@ -175,6 +177,9 @@ fn chunk(read_key: Buffer, metric: Option, final_chunk: bool) -> Raw struct MockStream { schema: Arc, source: RawLabelSource, + /// The label decode description this stream declares; a key-mode fake derives it from its + /// binding, as a frame producer would write it into the schema. + decode: Option, ordering: SeriesOrdering, items: VecDeque>>>, polls: Arc, @@ -197,6 +202,9 @@ impl RawSeriesStream for MockStream { fn label_source(&self) -> &RawLabelSource { &self.source } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.decode.as_ref() + } fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -216,10 +224,12 @@ fn mock_with( ) -> (SendableRawSeriesStream, Arc, Arc) { let polls = Arc::new(AtomicUsize::new(0)); let drops = Arc::new(AtomicUsize::new(0)); + let decode = label_decode_spec(&source); ( Box::pin(MockStream { schema, source, + decode, ordering, items: items.into(), polls: polls.clone(), @@ -807,6 +817,7 @@ async fn constructor_and_mode_errors_drop_input_without_polling_ahead() { let raw: SendableRawSeriesStream = Box::pin(MockStream { schema: invalid_schema, source: RawLabelSource::Explicit, + decode: None, ordering: SeriesOrdering::Contiguous, items: VecDeque::new(), polls: polls.clone(), @@ -1038,3 +1049,75 @@ async fn dense_and_sparse_sources_materialize_complete_labels() { assert_eq!(metric.tags().len(), 2); } } + +/// A consumer that received only the raw series frame decodes labels with no scan-bound source: +/// the producer's description travels inside the frame schema, so unpacking needs no binding and +/// materialization builds its decoder from that description alone. +#[tokio::test] +async fn materializes_labels_from_a_frame_with_no_supplied_binding() { + for encoding in [PrimaryKeyEncoding::Dense, PrimaryKeyEncoding::Sparse] { + let binding = source(encoding); + let description = + label_decode_spec(&binding).expect("a primary-key binding describes its key"); + let read_key = key( + encoding, + &[ + (8, Value::String("last".into())), + (2, Value::String("hidden".into())), + (41, Value::String("first".into())), + ], + ); + let (raw, _, _) = mock( + binding, + vec![Poll::Ready(Some(Ok(chunk(read_key.clone(), None, true))))], + ); + let framed = pack_raw_series(raw).unwrap(); + assert_eq!( + datatypes::series::frame::raw_series_frame_label_decode(framed.schema().as_ref()) + .unwrap(), + Some(description.clone()), + "{encoding:?}: the frame schema is the producer's only label statement" + ); + + // Everything the consumer keeps is the frame stream: no expected schema, no binding, no + // region metadata anywhere on the decode path. + let framed = unpack_raw_series(framed, None).unwrap(); + assert_eq!( + framed.label_decode_spec(), + Some(&description), + "{encoding:?}: the parsed description survives unpacking without a binding" + ); + assert!( + !matches!(framed.label_source(), RawLabelSource::PrimaryKey { .. }), + "{encoding:?}: no physical region key layout was supplied" + ); + assert_eq!( + framed + .schema() + .fields + .iter() + .map(|field| field.name.to_string()) + .collect::>(), + schema() + .fields + .iter() + .map(|field| field.name.to_string()) + .collect::>(), + "{encoding:?}: the consumer held no schema, so these are the producer's declared \ + fields read back from the frame" + ); + + let mut output = materialize_raw_labels(framed).unwrap(); + let materialized = output.next().await.unwrap().unwrap(); + assert_eq!(materialized.primary_key, read_key); + let metric = materialized + .metric + .expect("labels were materialized from the frame description"); + assert_eq!(metric.name(), Some("requests_total")); + assert_eq!(metric.get("zeta"), Some("last")); + assert_eq!(metric.get("alpha"), Some("first")); + assert_eq!(metric.get("private"), None); + assert_eq!(metric.tags().len(), 2); + assert!(output.next().await.is_none()); + } +} diff --git a/src/query/src/dist_plan/merge_scan.rs b/src/query/src/dist_plan/merge_scan.rs index d6a853620cb..a9107b49375 100644 --- a/src/query/src/dist_plan/merge_scan.rs +++ b/src/query/src/dist_plan/merge_scan.rs @@ -3419,6 +3419,99 @@ mod tests { } } + /// The frame's label decode description is compared as an exact string, so two sides that + /// derive it from equal bindings must produce byte-identical text, and any textual difference + /// must be a mismatch rather than a silent re-decode under one side. + #[test] + fn merge_scan_compares_label_decode_metadata_as_exact_text() { + use common_query::series_frame::{ + label_decode_spec, raw_series_frame_schema_with_ordering, + }; + use datatypes::series::SeriesOrdering; + use datatypes::series::frame::raw_series_frame_label_decode; + + let raw = raw_test_series_schema(); + // Two bindings that are equal but not the same allocation: the expectation and the + // advertised schema are built independently, as on a frontend and a datanode. + let binding = || { + let mut builder = + store_api::metadata::RegionMetadataBuilder::new(RegionId::new(1024, 1)); + builder + .push_column_metadata(store_api::metadata::ColumnMetadata { + column_schema: datatypes::schema::ColumnSchema::new( + "host", + ConcreteDataType::string_datatype(), + true, + ), + semantic_type: api::v1::SemanticType::Tag, + column_id: 7, + }) + .push_column_metadata(store_api::metadata::ColumnMetadata { + column_schema: datatypes::schema::ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ), + semantic_type: api::v1::SemanticType::Timestamp, + column_id: 8, + }) + .primary_key(vec![7]); + store_api::region_engine::RawLabelSource::PrimaryKey { + metric_name: "cpu_usage".into(), + physical_metadata: Arc::new(builder.build().unwrap()), + logical_tags: Arc::from([store_api::region_engine::BoundTag { + column_id: 7, + name: "host".into(), + }]), + } + }; + let expected_binding = binding(); + let actual_binding = binding(); + assert_eq!( + label_decode_spec(&expected_binding), + label_decode_spec(&actual_binding) + ); + let expected = raw_series_frame_schema_with_ordering( + &raw, + &expected_binding, + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + let actual = raw_series_frame_schema_with_ordering( + &raw, + &actual_binding, + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + let key = "greptime.series.label_decode"; + assert_eq!(expected.metadata().get(key), actual.metadata().get(key)); + assert!( + expected.metadata()[key].contains("\"encoding\":\"dense\""), + "the description is plain compact JSON: {}", + expected.metadata()[key] + ); + assert!( + validate_remote_schema(&expected, &actual, "test").is_ok(), + "equal bindings must compare equal" + ); + + // Semantically equal but textually different descriptions are rejected: the reserved + // metadata is compared as exact text, not parsed and compared structurally. + let mut spaced_metadata = expected.metadata().clone(); + let spaced_text = spaced_metadata[key].replacen(':', ": ", 1); + assert_ne!(spaced_text, spaced_metadata[key]); + spaced_metadata.insert(key.to_string(), spaced_text); + let spaced = ArrowSchema::new_with_metadata(expected.fields().clone(), spaced_metadata); + assert_eq!( + raw_series_frame_label_decode(&spaced) + .unwrap() + .map(|decode| decode.version), + Some(1), + "the spaced text is still a valid description" + ); + assert!(validate_remote_schema(&expected, &spaced, "test").is_err()); + } + fn raw_test_series_schema() -> SeriesSchema { SeriesSchema { fields: vec![ diff --git a/src/query/src/promql/native_compile.rs b/src/query/src/promql/native_compile.rs index 88e5023aa42..56ea7445b76 100644 --- a/src/query/src/promql/native_compile.rs +++ b/src/query/src/promql/native_compile.rs @@ -16,25 +16,38 @@ //! //! This is the front half of the native evaluation model: it reads the parsed statement and writes //! down what it means as bound nodes. It runs nothing, and nothing calls it yet, so the subset it -//! accepts is deliberately three shapes: +//! accepts is one recursive rule: //! //! ```text -//! e.g. x -//! ([]) e.g. rate(x[5m]) -//! (, []) e.g. quantile_over_time(0.9, x[5m]) +//! e.g. x +//! ([]) e.g. rate(x[5m]) +//! (, []) e.g. quantile_over_time(0.9, x[5m]) +//! ([, ]*) e.g. abs(rate(x[5m])), clamp_min(x, 0) //! ``` //! -//! which become `Input` (raw samples) → `Instant` (the last sample within the statement's lookback) -//! and `Input` (raw samples) → `Window` (the range function over the statement's grid). A selector -//! may carry `offset` in either shape and, in the window shape, an `@` anchor. Everything else is a -//! [`DataFusionError::NotImplemented`] that names what was found and quotes the shapes above, -//! because rejecting a query the compiler does not understand is the only honest alternative to -//! compiling it into something else. +//! which become `Input` (raw samples) → `Instant` (the last sample within the statement's lookback), +//! `Input` (raw samples) → `Window` (the range function over the statement's grid) and +//! `` → `Map` (a pointwise math function over what its argument evaluates to). A +//! selector may carry `offset` in either selection shape and, in the window shape, an `@` anchor. +//! Everything else is a [`DataFusionError::NotImplemented`] that names what was found and quotes the +//! shapes above, because rejecting a query the compiler does not understand is the only honest +//! alternative to compiling it into something else. +//! +//! Two decisions keep the recursion from growing a branch per query. A constant argument — a +//! quantile, a rounding step, a clamp bound — is folded out of the statement by [`constant_value`], +//! so `quantile_over_time(1 - 0.1, x[5m])` binds the number it means rather than the expression it +//! was written as. A math function is bound by [`math_op`] from a list of names, so +//! `clamp_min(abs(rate(x[5m])), 0)` is two maps over one window because the arguments nest, not +//! because the compiler knows the query. Arithmetic inside a fold is [`ArithmeticOp::calculate`]'s +//! alone, so the number this compiler binds and the number the kernel computes cannot disagree +//! about `%`, `^` or `atan2`. //! //! The statement's grid, the selector's lookback, window, offset and anchor are the only numbers //! derived here; the legality of the tree is not checked here. [`NativeTree::try_new`] is the //! single authority on that, so a node added later cannot be legal in one place and illegal in -//! another. +//! another. What is refused before a tree exists is the scan's shape: no shape below can consume +//! chunks of different series that interleave, so an `Interleaved` ordering is refused once, at the +//! entry, and [`NativeNode::validate`] refuses the same ordering for a tree built some other way. use std::sync::Arc; use std::time::Duration; @@ -43,25 +56,28 @@ use datafusion::error::{DataFusionError, Result}; use datatypes::series::SeriesOrdering; use datatypes::series::samples::SeriesSchema; use datatypes::series::time::{EvalGrid, GridSpec}; +use promql::map::{ArithmeticOp, MathOp}; use promql::native::plan::{ - BoundInput, BoundInstant, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, + BoundInput, BoundInstant, BoundMap, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, }; use promql::range_eval::{RangeFunction, RangeWindow}; +use promql_parser::parser::token::TokenType; +use promql_parser::parser::value::ValueType; use promql_parser::parser::{ AtModifier, Call, EvalStmt, Expr as PromExpr, MatrixSelector, NumberLiteral, Offset, - VectorSelector, + VectorSelector, token, }; use crate::promql::label_values::signed_millis_since_epoch; /// The shapes this compiler accepts, quoted in every rejection. -const SUPPORTED: &str = "a bare selector, a range function over a selector — \ - `([])` — or a parameterised one, \ - `(, [])`, for example `x`, \ - `rate(x[5m])` or `quantile_over_time(0.9, x[5m])`"; +const SUPPORTED: &str = "a selector, a range function over a range selector — \ + `([])`, with its constants in front of \ + the selector as in `quantile_over_time(0.9, x[5m])` — or a math function \ + over any of them, as in `abs(rate(x[5m]))`: for example `x`, \ + `rate(x[5m])`, `quantile_over_time(0.9, x[5m])` or `abs(rate(x[5m]))`"; -/// Compiles `stmt` into a tree that reads `input` and either selects from it or folds it with the -/// statement's one window. +/// Compiles `stmt` into a tree that reads `input` and evaluates the statement's expression over it. /// /// `input` is the raw series schema the caller bound from the table, and the caller has to apply the /// selector's matchers before binding it: `__field__`, `__schema__` and `__database__` select rows @@ -72,12 +88,12 @@ const SUPPORTED: &str = "a bare selector, a range function over a selector — \ /// one. /// /// `ordering` is what the caller's scan guarantees, and the tree records it rather than assuming -/// one. Only `Contiguous` and `KeyThenTime` are accepted: a native range window consumes one series -/// to its last chunk before the next, so chunks of different series may not interleave, and an -/// `Interleaved` declaration is refused here — which is where the refusal belongs, since a tree that -/// declares it under a window cannot be built by this compiler at all. [`NativeTree::validate`]'s -/// window branch checks the same thing, so if both land this refusal comes first and the tree check -/// is a backstop for a tree built some other way. +/// one. Only `Contiguous` and `KeyThenTime` are accepted: every shape this compiler accepts reads +/// one series to its last chunk before the next, so an `Interleaved` declaration is refused at the +/// entry, before any shape is built — the bare selector included, which returns before a window +/// exists — and a shape added later inherits the refusal instead of restating it. +/// [`NativeNode::validate`] refuses the same ordering in its `Instant` and `Window` branches, as a +/// backstop for a tree built some other way. /// /// The tree carries no metric name and no matchers, so `rate(x{job="a"}[5m])` and `rate(y[5m])` /// compile to identical trees, and an `EXPLAIN` built on this display cannot tell them apart. That @@ -88,55 +104,147 @@ pub fn compile( input: Arc, ordering: SeriesOrdering, ) -> Result { - // A bare selector is the other shape: an instant selection over the same raw input. - if let PromExpr::VectorSelector(selector) = unparen(&stmt.expr) { - return instant_selection(stmt, selector, input, ordering); + // Every shape this compiler accepts reads one series to its last chunk before the next, so raw + // samples whose chunks of different series interleave cannot be fed to any of them. The refusal + // is here rather than in a shape's branch so that a shape added later inherits it. + if ordering == SeriesOrdering::Interleaved { + return Err(not_implemented( + "raw samples whose series interleave, which this compiler cannot consume: a native \ + evaluation reads one series to its last chunk before the next, so chunks of different \ + series may not interleave", + )); } - let PromExpr::Call(Call { func, args }) = unparen(&stmt.expr) else { - return Err(not_implemented(&format!("the expression `{}`", stmt.expr))); + let context = Context { + stmt, + input, + ordering, }; - let Some(signature) = range_signature(func.name) else { + // The whole tree is validated once, here: a node compiled as another node's input is checked as + // part of the tree rather than on its own, so the same node cannot be legal in one place and + // illegal in another. + NativeTree::try_new(compile_expr(&context, &stmt.expr)?) +} + +/// What every node of one compilation derives from the statement rather than from the expression. +/// +/// The three are the same for the whole tree: one statement decides the grid, the lookback and the +/// anchors, one caller binds the raw input, and one scan guarantees the ordering. Passing them as +/// one value is what lets [`compile_expr`] recurse without restating what it was handed. +struct Context<'a> { + /// The statement being compiled. + stmt: &'a EvalStmt, + /// The raw series the caller bound; shared by every input node rather than copied per node. + input: Arc, + /// The ordering the caller's scan guarantees. + ordering: SeriesOrdering, +} + +/// Compiles `expr` into the node it denotes, recursing into whatever it is made of. +/// +/// One rule per kind of expression and nothing per query: a math function is compiled by compiling +/// its argument, so a query nests as deeply as it was written. A kind this compiler has no node for +/// is refused with what was found, which is why the rejection is useful without a coverage list. +fn compile_expr(context: &Context, expr: &PromExpr) -> Result { + match expr { + // Parentheses group, so they change how a statement was typed rather than what it asks for: + // the expression inside them is the one that compiles. + PromExpr::Paren(paren) => compile_expr(context, &paren.expr), + PromExpr::Call(call) => compile_call(context, call), + PromExpr::VectorSelector(selector) => instant_selection(context, selector), + found => Err(not_implemented(&format!("the expression `{found}`"))), + } +} + +/// Compiles a call: a range function opens a window over the selector it is given, and a math +/// function maps the value of its first argument. +/// +/// The two name lists are disjoint, and a range function is decided first because it binds its own +/// window rather than its argument's value. A name in neither list is refused here, which is where +/// the difference between "this compiler cannot fold this function" and "this name means nothing" +/// is still visible. +fn compile_call(context: &Context, call: &Call) -> Result { + let Call { func, args } = call; + if let Some(signature) = range_signature(func.name) { + return window_call(context, func.name, signature, &args.args); + } + // The name is looked up before the call's arguments are read, so a name this compiler has no + // kernel for is refused as such whichever arguments it was given: `pi()` and `time()` have no + // vector to map, and the rejection names the name rather than describing a vector they never + // wrote. + let Some((op, vector)) = math_op(func.name, &args.args)? else { return Err(not_implemented(&format!( - "a call to `{}`, which has no native range kernel", + "a call to `{}`, which has no native kernel", func.name ))); }; + map_call(context, func.name, op, vector) +} + +/// Binds a range function over the range selector it is given, and the window that selector opens. +/// +/// The parameters come first and the range selector is last, for both signatures: a function without +/// parameters has no `parameters` to read. +fn window_call( + context: &Context, + name: &str, + signature: RangeSignature, + args: &[Box], +) -> Result { // The count is the signature's, so the rejection names what this function takes rather than // what one argument happens to be. let expected = signature.parameters() + 1; - if args.args.len() != expected { + if args.len() != expected { return Err(not_implemented(&format!( - "a call to `{}` with {} argument(s), where it takes {expected}: {}", - func.name, - args.args.len(), + "a call to `{name}` with {} argument(s), where it takes {expected}: {}", + args.len(), signature.arguments() ))); } - // The parameters come first and the range selector is last, for both signatures: a function - // without parameters has no `parameters` to read. - let (parameters, selector) = args.args.split_at(signature.parameters()); + let (parameters, selector) = args.split_at(signature.parameters()); let function = match signature { RangeSignature::Selector(function) => function, - RangeSignature::Scalar(bind) => bind(scalar_parameter(func.name, ¶meters[0])?), + RangeSignature::Scalar(bind) => bind(constant_argument(name, ¶meters[0], 0)?), }; let argument = &selector[0]; let PromExpr::MatrixSelector(MatrixSelector { vs, range }) = unparen(argument) else { return Err(not_implemented(&format!( - "a call to `{}` whose argument is `{}`", - func.name, argument + "a call to `{name}` whose argument is `{argument}`" ))); }; - // A native range window reads one series to its last chunk before it reads the next, so raw - // samples whose chunks of different series interleave cannot be folded by it. The declaration - // is refused here rather than recorded onto an input the tree is built over and fails on. - if ordering == SeriesOrdering::Interleaved { - return Err(not_implemented( - "raw samples whose series interleave, which this compiler cannot consume: its range \ - window reads one series to its last chunk before the next, so chunks of different \ - series may not interleave", - )); + window_selection(context, function, vs, range) +} + +/// Wraps `op` over the value of `vector`, which is the first argument of a math function. +/// +/// The output is the input's own type: a pointwise function moves no value to another grid point +/// and reports one value per field it was given, so the map's schema is the input's allocation +/// rather than a copy that merely agrees with it. [`NativeTree::validate`] does not require that +/// sharing: it compares the map's grid with its input's by [`EvalGrid::spec`] and its field count +/// with its input's, so a map that allocated an equal grid of its own would validate the same way. +/// The only comparison by identity is an input node's, whose output has to be the very binding it +/// was bound to. +fn map_call(context: &Context, name: &str, op: MathOp, vector: &PromExpr) -> Result { + // A math function is pointwise over a vector, so its first argument has to be one. The parser + // checks the same thing and the tree's validation checks the compiled input's type again, but + // neither can name the argument the client wrote: this is the check that can. + if vector.value_type() != ValueType::Vector { + return Err(not_implemented(&format!( + "a call to `{name}` whose first argument is `{vector}`, which is a {}, where the \ + function takes a vector", + vector.value_type() + ))); } - window_selection(stmt, function, vs, range, input, ordering) + let input = compile_expr(context, vector)?; + let output = input.output.clone(); + Ok(NativeNode { + expr: NativeExpr::Map { + input: Box::new(input), + op: BoundMap { + ops: Arc::from([op]), + }, + }, + output, + }) } /// Binds `function` over the range selector `vs[range]` into a window over the raw input. @@ -145,39 +253,37 @@ pub fn compile( /// whichever function reads them, so a parameterised function only decides *what* folds the window, /// never *which* window is folded. fn window_selection( - stmt: &EvalStmt, + context: &Context, function: RangeFunction, vs: &VectorSelector, range: &Duration, - input: Arc, - ordering: SeriesOrdering, -) -> Result { +) -> Result { // An `@` anchor is step-invariant: every step folds the same samples, which the window's // `anchor_ms` tells `RangeEvaluator` to replay over the grid. let range_ms = range_millis(range.as_millis())?; let offset_ms = selector_offset_ms(&vs.offset)?; - let anchor_ms = selector_anchor_ms(&vs.at, stmt)?; + let anchor_ms = selector_anchor_ms(&vs.at, context.stmt)?; // The grid is the last thing built: a query refused above, or a window that cannot exist, // never pays for a grid nothing would read. - let output_grid = statement_grid(stmt)?; + let output_grid = statement_grid(context.stmt)?; // A range function reports one value per input field, so the layout is the input's. let output = NativeType::Samples(Arc::new(SeriesSchema { - fields: Arc::clone(&input.fields), + fields: Arc::clone(&context.input.fields), eval_grid: Some(Arc::clone(&output_grid)), })); // One allocation shared by the binding and the node's output: `NativeTree::try_new` compares // them by identity, because a compiler that meant one schema has no reason to allocate it // twice. - let raw = NativeType::Samples(input); + let raw = NativeType::Samples(Arc::clone(&context.input)); - NativeTree::try_new(NativeNode { + Ok(NativeNode { expr: NativeExpr::Window { input: Box::new(NativeNode { expr: NativeExpr::Input(BoundInput { output: raw.clone(), - ordering, + ordering: context.ordering, }), output: raw, }), @@ -201,12 +307,7 @@ fn window_selection( /// /// The lookback, the offset and the grid are the whole of an instant selection: it reports the last /// sample at or before each grid point, within the statement's lookback. -fn instant_selection( - stmt: &EvalStmt, - selector: &VectorSelector, - input: Arc, - ordering: SeriesOrdering, -) -> Result { +fn instant_selection(context: &Context, selector: &VectorSelector) -> Result { // An `@` anchor is bound on a range window, which carries `anchor_ms`; an instant selection has // no such field, so the anchor has nowhere to go. Refusing it is the only alternative to // dropping it, which would silently select around every grid point instead of one anchor. @@ -217,25 +318,25 @@ fn instant_selection( anchoring is bound on a range window, as in `rate(x[5m] @ 1234)`" ))); } - let lookback_ms = lookback_millis(stmt.lookback_delta.as_millis())?; + let lookback_ms = lookback_millis(context.stmt.lookback_delta.as_millis())?; let offset_ms = selector_offset_ms(&selector.offset)?; - let output_grid = statement_grid(stmt)?; + let output_grid = statement_grid(context.stmt)?; // An instant selection reports one value per input field, so the layout is the input's. let output = NativeType::Samples(Arc::new(SeriesSchema { - fields: Arc::clone(&input.fields), + fields: Arc::clone(&context.input.fields), eval_grid: Some(Arc::clone(&output_grid)), })); // One allocation shared by the binding and the node's output, as in the window shape. - let raw = NativeType::Samples(input); + let raw = NativeType::Samples(Arc::clone(&context.input)); - NativeTree::try_new(NativeNode { + Ok(NativeNode { expr: NativeExpr::Instant { input: Box::new(NativeNode { expr: NativeExpr::Input(BoundInput { output: raw.clone(), - ordering, + ordering: context.ordering, }), output: raw, }), @@ -351,10 +452,10 @@ fn grid_error(error: datatypes::error::Error) -> DataFusionError { enum RangeSignature { /// A function that reads only its range selector, as in `rate(x[5m])`. Selector(RangeFunction), - /// A function that reads one number literal before its range selector, and the function that - /// number binds, as in `quantile_over_time(0.9, x[5m])`. + /// A function that reads one constant before its range selector, and the function that constant + /// binds, as in `quantile_over_time(0.9, x[5m])`. /// - /// The number is kept out of the variant name because only the call site knows it. + /// The constant is kept out of the variant name because only the call site knows it. Scalar(fn(f64) -> RangeFunction), } @@ -376,7 +477,7 @@ impl RangeSignature { } } -/// The signature the PromQL name calls, or `None` when this compiler has no kernel for it. +/// The signature the PromQL name calls, or `None` when this compiler has no range kernel for it. /// /// The map is explicit on purpose, as in [`range_function`]: a name that is not listed is rejected, /// so supporting one more function is a decision made in one place rather than a consequence of a @@ -389,29 +490,235 @@ fn range_signature(name: &str) -> Option { } } -/// The number a parameterised range function was given, as the literal it was written as. +/// What a call to a math function has to be: the constants it reads after its vector, and how to +/// bind them. /// -/// A number literal is the only parameter this compiler binds. A parameter whose value is only -/// known at run time, `time()` being the real case, would make the kernel's constant a query-time -/// value, which the tree has no field for. +/// The shapes are separated by how many constants the function reads, because that is the only +/// thing the variants differ in: a name maps to one kernel, and the constants are bound from the +/// call. `round` is the one function whose constant may be left out, so it is its own shape: the +/// default is a decision about the function rather than about the constant. +#[derive(Clone, Copy)] +enum MathSignature { + /// A function of its vector alone, as in `abs`. + Unary(MathOp), + /// A function of its vector and one constant, as in `clamp_min`. + OneConstant(fn(f64) -> MathOp), + /// A function of its vector and one constant it may be called without, as in `round`. + DefaultedConstant { + default: f64, + bind: fn(f64) -> MathOp, + }, + /// A function of its vector and two constants, as in `clamp`. + TwoConstants(fn(f64, f64) -> MathOp), +} + +impl MathSignature { + /// How many constants the function reads when every one of them is written. + fn parameters(self) -> usize { + match self { + Self::Unary(_) => 0, + Self::OneConstant(_) | Self::DefaultedConstant { .. } => 1, + Self::TwoConstants(_) => 2, + } + } + + /// How many constants the function has to be given. + fn required(self) -> usize { + match self { + Self::DefaultedConstant { .. } => 0, + _ => self.parameters(), + } + } + + /// What the call's arguments are, for the arity rejection to name them. + fn arguments(self) -> &'static str { + match self { + Self::Unary(_) => "its vector", + Self::OneConstant(_) => "its vector and one constant", + Self::DefaultedConstant { .. } => "its vector and one optional constant", + Self::TwoConstants(_) => "its vector and two constants", + } + } +} + +/// The signature the PromQL name calls, or `None` when this compiler has no math kernel for it. /// -/// A parameter written as an expression the existing planner folds to a literal is refused here -/// and is a gap this stage leaves open on purpose: `quantile_over_time(1 - 0.1, x[5m])` and -/// `quantile_over_time(-(0.9), x[5m])` plan today through `try_build_literal_expr`, which folds -/// `Paren`, `Unary` and literal-only `Binary`, and reach this function still unfolded. They are -/// refused rather than answered from the wrong number, and folding them here would be the work -/// to close the gap. +/// The map is explicit on purpose, as in [`range_function`]: a name that is not listed is refused +/// rather than treated as math because it looks mathematical, so adding one more function is a +/// decision made in one place. A name in this list is a promise that its kernel exists, which is why +/// arity is a property of the variant rather than of the caller. +fn math_signature(name: &str) -> Option { + match name { + "abs" => Some(MathSignature::Unary(MathOp::Abs)), + "ceil" => Some(MathSignature::Unary(MathOp::Ceil)), + "floor" => Some(MathSignature::Unary(MathOp::Floor)), + "sqrt" => Some(MathSignature::Unary(MathOp::Sqrt)), + "exp" => Some(MathSignature::Unary(MathOp::Exp)), + "ln" => Some(MathSignature::Unary(MathOp::Ln)), + "log2" => Some(MathSignature::Unary(MathOp::Log2)), + "log10" => Some(MathSignature::Unary(MathOp::Log10)), + "sin" => Some(MathSignature::Unary(MathOp::Sin)), + "cos" => Some(MathSignature::Unary(MathOp::Cos)), + "tan" => Some(MathSignature::Unary(MathOp::Tan)), + "asin" => Some(MathSignature::Unary(MathOp::Asin)), + "acos" => Some(MathSignature::Unary(MathOp::Acos)), + "atan" => Some(MathSignature::Unary(MathOp::Atan)), + "sinh" => Some(MathSignature::Unary(MathOp::Sinh)), + "cosh" => Some(MathSignature::Unary(MathOp::Cosh)), + "tanh" => Some(MathSignature::Unary(MathOp::Tanh)), + "asinh" => Some(MathSignature::Unary(MathOp::Asinh)), + "acosh" => Some(MathSignature::Unary(MathOp::Acosh)), + "atanh" => Some(MathSignature::Unary(MathOp::Atanh)), + "deg" => Some(MathSignature::Unary(MathOp::Deg)), + "rad" => Some(MathSignature::Unary(MathOp::Rad)), + "sgn" => Some(MathSignature::Unary(MathOp::Sgn)), + // `round`'s nearest defaults to one, so `round(x)` is `round(x, 1)` and its variant always + // carries the number it rounds to. + "round" => Some(MathSignature::DefaultedConstant { + default: 1.0, + bind: |nearest| MathOp::Round { nearest }, + }), + "clamp" => Some(MathSignature::TwoConstants(|min, max| MathOp::Clamp { + min, + max, + })), + "clamp_min" => Some(MathSignature::OneConstant(|min| MathOp::ClampMin { min })), + "clamp_max" => Some(MathSignature::OneConstant(|max| MathOp::ClampMax { max })), + _ => None, + } +} + +/// The pointwise kernel `name` denotes over its vector, and that vector, or `None` when this +/// compiler has no math kernel for that name. +/// +/// A name that is not in [`math_signature`] is not math: the caller reports that it has no kernel +/// instead of compiling it into a kernel that would compute something else. `arguments` is the +/// call's whole argument list, because the vector is its first argument: the arity is the +/// signature's and counts the vector in, so a call with no argument at all is refused as a call +/// that wrote none rather than as a shape this compiler cannot bind. The arguments after the vector +/// are the call's constants, in the order they were written, and each is folded to the number it +/// means by [`constant_value`], because the tree binds a kernel's constants and has no field for an +/// expression. +fn math_op<'a>( + name: &str, + arguments: &'a [Box], +) -> Result> { + let Some(signature) = math_signature(name) else { + return Ok(None); + }; + // The vector is the function's first argument, so a call that wrote no argument at all is + // refused by the same arity check as one that wrote too few constants. + let Some((vector, parameters)) = arguments.split_first() else { + return Err(arity_error(name, signature, 0)); + }; + let given = parameters.len() + 1; + if given < signature.required() + 1 || given > signature.parameters() + 1 { + return Err(arity_error(name, signature, given)); + } + // The vector is the first argument, so the constants are read from the second onwards. + let op = match signature { + MathSignature::Unary(op) => op, + MathSignature::OneConstant(bind) => bind(constant_argument(name, ¶meters[0], 1)?), + MathSignature::DefaultedConstant { default, bind } => bind(match parameters.first() { + Some(parameter) => constant_argument(name, parameter, 1)?, + None => default, + }), + MathSignature::TwoConstants(bind) => bind( + constant_argument(name, ¶meters[0], 1)?, + constant_argument(name, ¶meters[1], 2)?, + ), + }; + Ok(Some((op, vector))) +} + +/// The rejection of a math call that wrote `given` arguments where `signature` takes another count. +/// +/// The count is the signature's, so the message says what this function takes rather than what one +/// argument happens to be. The vector is counted in: it is the function's first argument, and a +/// reader counting the call's arguments should not have to add it themselves. +fn arity_error(name: &str, signature: MathSignature, given: usize) -> DataFusionError { + not_implemented(&format!( + "a call to `{name}` with {given} argument(s), where it takes {}", + signature.arguments() + )) +} + +/// The constant `argument` folds to, or a rejection naming the argument and where it was read. +/// +/// A parameter is a constant of the statement: a quantile, a rounding step or a clamp bound is a +/// field of the tree, so a parameter whose value is only known at run time — `time()` being the real +/// case — has nowhere to go. It is refused rather than answered from the wrong number, and the +/// rejection names the expression, because that is what the query has to change. /// /// A quantile outside `[0, 1]` is not refused here: Prometheus binds it and the kernel reports -/// `-Infinity` below zero and `+Infinity` above one, so refusing it would be this compiler -/// inventing a rule the query engine does not have. -fn scalar_parameter(name: &str, parameter: &PromExpr) -> Result { - match unparen(parameter) { - PromExpr::NumberLiteral(NumberLiteral { val }) => Ok(*val), - found => Err(not_implemented(&format!( - "a call to `{name}` whose first argument is `{found}`, where it has to be a number \ - literal" - ))), +/// `-Infinity` below zero and `+Infinity` above one, so refusing it would be this compiler inventing +/// a rule the query engine does not have. +fn constant_argument(name: &str, argument: &PromExpr, position: usize) -> Result { + constant_value(argument).ok_or_else(|| { + not_implemented(&format!( + "a call to `{name}` whose {} is `{argument}`, where it has to be a constant expression \ + this compiler folds to a number literal", + argument_position(position) + )) + }) +} + +/// How a rejection names the argument a parameter was read from. +/// +/// The three positions are the ones a signature can read: the vector, and the two constants behind +/// it. Every caller passes the literal position its signature reads, so there is no fourth position +/// to name and no default to invent one for. +fn argument_position(position: usize) -> &'static str { + const POSITIONS: [&str; 3] = ["first argument", "second argument", "third argument"]; + POSITIONS[position] +} + +/// The number `expr` folds to, or `None` when its value is not the statement's to know. +/// +/// Only what the statement itself decides is folded: a number literal, a group around one, a negated +/// one, and arithmetic whose two operands both fold. A call, a selector, a range selector, an +/// aggregate and a subquery are `None`, because their value comes from the data or from the +/// evaluation time, and a kernel's constant is a field of the tree rather than something bound while +/// it runs. Returning `None` rather than an error is what lets the caller report where the +/// expression was found, which is the useful half of the refusal. +/// +/// Arithmetic is [`ArithmeticOp::calculate`]'s and nothing else's: that is where Prometheus' +/// semantics for `%`, `^` and `atan2` live, the last two through `go_math`, so defining them again +/// here would be a second answer for the two to disagree over. An operator with no +/// [`ArithmeticOp`] — a comparison, `and`, `or`, `unless` — is not arithmetic and does not fold. +fn constant_value(expr: &PromExpr) -> Option { + match expr { + PromExpr::NumberLiteral(NumberLiteral { val }) => Some(*val), + PromExpr::Paren(paren) => constant_value(&paren.expr), + PromExpr::Unary(unary) => constant_value(&unary.expr).map(|value| -value), + PromExpr::Binary(binary) => { + let op = arithmetic_op(&binary.op)?; + let left = constant_value(&binary.lhs)?; + let right = constant_value(&binary.rhs)?; + Some(op.calculate(left, right)) + } + // Calls (`time()`, `scalar(v)`), selectors, range selectors, aggregates and subqueries all + // read something this compiler cannot know before the query runs. + _ => None, + } +} + +/// The arithmetic operator a PromQL token denotes, or `None` when the token is not arithmetic. +/// +/// The token is matched by its id, which is stable and is what the rest of the planner matches on, +/// rather than by the text it displays as: a symbol this compiler does not recognise is not +/// arithmetic here rather than a wrong operator. A comparison and a set operator are the cases that +/// reach this and fold nothing, which is this batch's boundary rather than an accident of the map. +fn arithmetic_op(op: &TokenType) -> Option { + match op.id() { + token::T_ADD => Some(ArithmeticOp::Add), + token::T_SUB => Some(ArithmeticOp::Subtract), + token::T_MUL => Some(ArithmeticOp::Multiply), + token::T_DIV => Some(ArithmeticOp::Divide), + token::T_MOD => Some(ArithmeticOp::Modulo), + token::T_POW => Some(ArithmeticOp::Power), + token::T_ATAN2 => Some(ArithmeticOp::Atan2), + _ => None, } } @@ -447,7 +754,8 @@ fn range_function(name: &str) -> Option { /// /// Parentheses group, so they say nothing about the expression inside them: `rate((x[5m]))` asks /// for exactly what `rate(x[5m])` asks for. Refusing it would reject a statement on the way it was -/// typed rather than on what it means. +/// typed rather than on what it means. A range selector is not an expression this compiler +/// compiles on its own, so the call that reads one looks through the grouping itself. fn unparen(mut expr: &PromExpr) -> &PromExpr { while let PromExpr::Paren(paren) = expr { expr = &paren.expr; diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index 5e375858111..f16bb74b072 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -18,8 +18,9 @@ use std::time::{Duration, UNIX_EPOCH}; use datafusion::error::DataFusionError; use datatypes::series::SeriesOrdering; use datatypes::series::samples::{FieldSpec, SeriesSchema}; +use promql::map::MathOp; use promql::native::plan::{ - BoundInstant, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, + BoundInstant, BoundMap, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, }; use promql::range_eval::RangeFunction; use promql_parser::parser::{EvalStmt, Expr as PromExpr, NumberLiteral}; @@ -82,6 +83,18 @@ fn with_call_arguments(query: &str, edit: impl FnOnce(&mut Vec>)) stmt } +/// The range selector out of `rate(x[5m])`. +/// +/// The parser type-checks function arguments, so a range selector can only be handed to a function +/// that does not take one by editing a tree the parser accepted. +fn a_matrix_selector() -> Box { + let mut stmt = eval_stmt("rate(x[5m])"); + let PromExpr::Call(call) = &mut stmt.expr else { + unreachable!("`rate(x[5m])` parses to a call"); + }; + call.args.args.remove(0) +} + /// The window at the root of `tree`, for the range-function shape. fn root_window(tree: &NativeTree) -> (&NativeNode, &BoundWindow) { let NativeExpr::Window { input, op } = &tree.root().expr else { @@ -101,6 +114,32 @@ fn root_instant(tree: &NativeTree) -> (&NativeNode, &BoundInstant) { (input, op) } +/// The input and the operation of a `Map` node. +fn map_parts(node: &NativeNode) -> (&NativeNode, &BoundMap) { + let NativeExpr::Map { input, op } = &node.expr else { + panic!("the node is not a map: {:?}", node.expr); + }; + (input, op) +} + +/// The one operation a `Map` node applies. +fn map_op(node: &NativeNode) -> MathOp { + let (_, op) = map_parts(node); + let [only] = &op.ops[..] else { + panic!("the map holds {} operation(s)", op.ops.len()); + }; + *only +} + +/// The schema a node's evaluated output carries. +fn evaluated(node: &NativeNode) -> &Arc { + let Some(schema) = node.output.samples() else { + panic!("the node does not produce samples: {:?}", node.output); + }; + assert!(schema.eval_grid.is_some(), "the node is not evaluated"); + schema +} + /// `rate(x[5m])` becomes a window over the raw input, on the statement's grid. #[test] fn a_rate_over_a_selector_becomes_a_window_over_raw_input() { @@ -277,27 +316,29 @@ fn a_parenthesised_selector_binds_as_written() { } } -/// A parameter the planner folds to a literal is still refused, and that gap is deliberate: the -/// kernel's constant is a field of the tree, so binding `1 - 0.1` means folding it here rather than -/// carrying an expression. A refusal is the agreed behaviour for this stage; an answer computed -/// from the wrong number would not be. +/// A parameter written as an expression binds the number it folds to, because the kernel's constant +/// is a field of the tree: `1 - 0.1` is the quantile `0.9` written two ways, and carrying the +/// expression would bind nothing at all. The value is asserted exactly, so a fold that produced a +/// neighbouring number rather than the arithmetic's own result would fail here. #[test] -fn a_parameter_folded_from_an_expression_is_refused() { - for query in [ - "quantile_over_time(1 - 0.1, x[5m])", - "quantile_over_time(-(0.9), x[5m])", - ] { - let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) - .expect_err(&format!("`{query}` compiled, and it should not have")); - assert!( - matches!(error, DataFusionError::NotImplemented(_)), - "`{query}` did not report NotImplemented: {error}" - ); - let message = error.to_string(); - assert!( - message.contains("number literal"), - "`{query}` did not say what the argument has to be: {message}" +fn a_parameter_folded_from_an_expression_binds_its_value() { + // `1 - 0.1` is `0.9` bit for bit in IEEE754, and `-(0.9)` is the negative of the same number: + // negation is exact, and the fold does not decide whether a quantile is inside `[0, 1]`. + let cases = [ + ("quantile_over_time(1 - 0.1, x[5m])", 0.9), + ("quantile_over_time(-(0.9), x[5m])", -0.9), + ]; + + for (query, quantile) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = root_window(&tree); + assert_eq!( + op.function, + RangeFunction::QuantileOverTime(quantile), + "`{query}` did not bind the quantile it folds to" ); + assert_eq!(op.window.range_ms, 300_000, "`{query}` lost its window"); } } @@ -369,6 +410,23 @@ fn a_call_with_the_wrong_number_of_arguments_names_the_count_it_takes() { "rate", "where it takes 1", ), + // A math function's constants are counted the same way: `clamp` reads two of them after its + // vector, so one is one too few. + ( + with_call_arguments("clamp(x, 0, 1)", |args| { + args.pop(); + }), + "clamp", + "where it takes its vector and two constants", + ), + // A listed math name called with no argument at all: the vector is counted as the first + // argument, so a call that wrote none is one argument short of `abs` rather than a shape + // the compiler has no words for. + ( + with_call_arguments("abs(x)", |args| args.clear()), + "abs", + "with 0 argument(s), where it takes its vector", + ), ]; for (stmt, name, expected) in cases { @@ -631,11 +689,11 @@ fn the_numeric_edges_of_a_statement_are_bound_as_given() { SeriesOrdering::KeyThenTime, Expected::Rejected(Rejection::Plan), ), - // A range window reads one series to its last chunk before the next, so a scan whose - // chunks of different series interleave is refused instead of recorded onto an input the - // window would fail on. + // Every shape reads one series to its last chunk before the next, so a scan whose chunks + // of different series interleave is refused instead of recorded onto an input that shape + // would fail on. ( - "an interleaved ordering, which a window cannot consume to its last chunk", + "an interleaved ordering, which no shape can consume to its last chunk", eval_stmt("rate(x[5m])"), SeriesOrdering::Interleaved, Expected::Rejected(Rejection::NotImplemented), @@ -707,6 +765,46 @@ fn the_numeric_edges_of_a_statement_are_bound_as_given() { } } +/// An interleaved scan is refused whatever shape the statement has. The refusal sits at the entry +/// of the compiler rather than in one shape's branch, because a bare selector returns before any +/// window exists and would otherwise be accepted with an ordering it cannot be evaluated on. +#[test] +fn an_interleaved_scan_is_refused_whatever_the_shape() { + // The plain range function is the ordering table's case, which compiles `rate(x[5m])` under + // `Interleaved` and expects the same refusal; these cover the shapes that table cannot: the + // bare selector, a math function over one, and a parameterised range function. + for query in ["x", "abs(x)", "quantile_over_time(0.9, x[5m])"] { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::Interleaved) + .expect_err(&format!("`{query}` compiled over an interleaved scan")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("reads one series to its last chunk before the next"), + "`{query}` did not say why the ordering is refused: {message}" + ); + } + // The same scan compiles a bare selector when its chunks are contiguous, and the ordering it + // was compiled under is recorded on the tree's input rather than dropped: that is what makes + // the refusals above about the ordering rather than about the shape. + let tree = compile(&eval_stmt("x"), raw_schema(), SeriesOrdering::Contiguous) + .expect("a contiguous scan compiles a bare selector"); + let (input, _) = root_instant(&tree); + let NativeExpr::Input(bound) = &input.expr else { + panic!( + "an instant selection's input is not an input: {:?}", + input.expr + ); + }; + assert_eq!( + bound.ordering, + SeriesOrdering::Contiguous, + "the instant selection dropped the scan's ordering" + ); +} + /// A shape outside the subset is refused, and the error says what was found so the query can be /// rewritten rather than guessed at. #[test] @@ -725,12 +823,16 @@ fn an_unsupported_shape_is_rejected_and_names_what_it_found() { // A function with no kernel, whether or not it takes a constant argument. ( "predict_linear(x[5m], 300)", - "a call to `predict_linear`, which has no native range kernel", + "a call to `predict_linear`, which has no native kernel", ), ( "absent(x)", - "a call to `absent`, which has no native range kernel", + "a call to `absent`, which has no native kernel", ), + // The two functions PromQL calls with no arguments at all: neither has a kernel, and the + // rejection names the name rather than describing a vector they were never given. + ("pi()", "a call to `pi`, which has no native kernel"), + ("time()", "a call to `time`, which has no native kernel"), ]; for (query, named) in cases { @@ -800,3 +902,260 @@ fn parentheses_around_the_accepted_shape_are_transparent() { ); } } +/// A math function compiles by compiling the expression it is applied to, so its node sits over the +/// node that expression produces: `abs(rate(x[5m]))` is a map over the window `rate(x[5m])` is, not +/// a fourth shape the compiler knows by name. The same rule reads `abs(x)` over an instant +/// selection, which is why the two are asserted together: nothing below the map is specific to it. +#[test] +fn a_math_function_maps_over_the_node_its_argument_compiles_to() { + let tree = compile( + &eval_stmt("abs(rate(x[5m]))"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`abs(rate(x[5m]))` was refused: {error}")); + + assert!( + matches!(map_op(tree.root()), MathOp::Abs), + "the root is not an abs: {:?}", + map_parts(tree.root()).1.ops + ); + let (window, _) = map_parts(tree.root()); + let NativeExpr::Window { input, op } = &window.expr else { + panic!("the map's input is not a window: {:?}", window.expr); + }; + assert_eq!(op.function, RangeFunction::Rate); + assert_eq!(op.window.range_ms, 300_000); + let NativeExpr::Input(bound) = &input.expr else { + panic!("the window's input is not an input: {:?}", input.expr); + }; + assert!( + matches!(&bound.output, NativeType::Samples(schema) if schema.eval_grid.is_none()), + "the window's input is not raw samples: {:?}", + bound.output + ); + assert_eq!(bound.ordering, SeriesOrdering::KeyThenTime); + + // The same rule over the other selection shape: a map over an instant selection. + let tree = compile( + &eval_stmt("abs(x)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`abs(x)` was refused: {error}")); + assert!(matches!(map_op(tree.root()), MathOp::Abs)); + let (instant, _) = map_parts(tree.root()); + assert!( + matches!(instant.expr, NativeExpr::Instant { .. }), + "`abs(x)`'s map is not over an instant selection: {:?}", + instant.expr + ); +} + +/// Nested math functions are two maps over one window, and every map reports that window's layout. +/// +/// The maps are separate nodes because the arguments nest, not because the compiler knows +/// `clamp_min(abs(...))`: a compiler that bound the pair to one node would fail the tree assertions +/// here. The grids are compared by their spec, which is what [`NativeTree::validate`] compares: a +/// map that allocated a second, equal grid would still be the window's layout, while one that +/// reported a different grid or different fields would not. +#[test] +fn nested_math_functions_are_two_maps_over_one_window() { + let tree = compile( + &eval_stmt("clamp_min(abs(rate(x[5m])), 0)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`clamp_min(abs(rate(x[5m])), 0)` was refused: {error}")); + + let MathOp::ClampMin { min } = map_op(tree.root()) else { + panic!( + "the root is not a clamp_min: {:?}", + map_parts(tree.root()).1.ops + ); + }; + assert_eq!( + min, 0.0, + "the clamp bound is not the number the query wrote" + ); + + let (inner, _) = map_parts(tree.root()); + assert!( + matches!(map_op(inner), MathOp::Abs), + "the inner node is not an abs: {:?}", + map_parts(inner).1.ops + ); + let (window, op) = map_parts(inner); + let NativeExpr::Window { op: bound, .. } = &window.expr else { + panic!("the inner map's input is not a window: {:?}", window.expr); + }; + assert_eq!(bound.function, RangeFunction::Rate); + assert_eq!(op.ops.len(), 1, "a map applies one operation"); + + let window_schema = evaluated(window); + let window_grid = window_schema + .eval_grid + .as_ref() + .expect("the window is evaluated"); + for (what, node) in [("the clamp_min", tree.root()), ("the abs", inner)] { + let schema = evaluated(node); + assert_eq!( + schema + .eval_grid + .as_ref() + .expect("the map is evaluated") + .spec(), + window_grid.spec(), + "`{what}` reports a grid the window does not" + ); + assert!( + schema + .fields + .iter() + .map(|field| &field.name) + .eq(window_schema.fields.iter().map(|field| &field.name)), + "`{what}` reports fields the window does not" + ); + } +} + +/// Every listed math name binds its own kernel, with the constants the call wrote. The display names +/// the operation and its constants, so a name that fell through to a neighbour's variant — or a +/// parameter that was bound from the wrong argument — shows up in a line someone would read. +#[test] +fn the_supported_math_functions_map_to_their_kernels() { + let cases = [ + ("abs(x)", "abs"), + ("ceil(x)", "ceil"), + ("floor(x)", "floor"), + ("sqrt(x)", "sqrt"), + ("exp(x)", "exp"), + ("ln(x)", "ln"), + ("log2(x)", "log2"), + ("log10(x)", "log10"), + ("sin(x)", "sin"), + ("cos(x)", "cos"), + ("tan(x)", "tan"), + ("asin(x)", "asin"), + ("acos(x)", "acos"), + ("atan(x)", "atan"), + ("sinh(x)", "sinh"), + ("cosh(x)", "cosh"), + ("tanh(x)", "tanh"), + ("asinh(x)", "asinh"), + ("acosh(x)", "acosh"), + ("atanh(x)", "atanh"), + ("deg(x)", "deg"), + ("rad(x)", "rad"), + ("sgn(x)", "sgn"), + // The three shapes that carry constants, including the default `round` binds one for. + ("round(x)", "round 1"), + ("round(x, 0.5)", "round 0.5"), + ("clamp(x, 0, 1)", "clamp 0 1"), + ("clamp_min(x, 0)", "clamp_min 0"), + ("clamp_max(x, 1)", "clamp_max 1"), + ]; + + for (query, op) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let display = tree.to_string(); + let root = display.lines().nth(1).expect("a compiled tree has a root"); + assert_eq!( + root, + format!(" root Map {op}"), + "`{query}` bound the wrong kernel" + ); + } +} + +/// The constants a function folds are folded by the kernel's own arithmetic, not by a second copy of +/// it: `%` keeps the sign of its left operand and `^` is `go_math`'s power. Both numbers below are +/// ones a hand-written copy would get wrong: a Euclidean modulo folds `-7 % 3` to 2, `*` folds the +/// power to about 9.8e8, and Rust's own `f64::powf` folds it to 3.623449445683154e291 where the +/// kernel gives 3.6234491766672406e291, because `go_math::pow` accumulates error by repeated +/// squaring as Go's `math.Pow` does. That is the point: the compiler must not disagree with the +/// kernel about what an operator means. +#[test] +fn the_constant_fold_is_the_kernels_arithmetic() { + // Go's `math.Mod` gives -1 for `-7 % 3`; a Euclidean modulo would give 2. + let tree = compile( + &eval_stmt("clamp_min(x, (-7) % 3)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`clamp_min(x, (-7) % 3)` was refused: {error}")); + let MathOp::ClampMin { min } = map_op(tree.root()) else { + panic!( + "the root is not a clamp_min: {:?}", + map_parts(tree.root()).1.ops + ); + }; + assert_eq!(min, -1.0, "`-7 % 3` did not fold with `math.Mod`'s sign"); + + // `1.0000006850274261 ^ 9.80019037e8` is a power over a base barely above one: as multiplication + // it would be about 9.8e8, and it is neither a comparison nor a set operator, which fold nothing + // at all. It is also the case `go_math` keeps its own `pow` for: Go's repeated squaring + // accumulates error the standard library's `pow` does not, so the two disagree well above the + // last bit and a fold that hand-wrote `powf` binds a different number here. + let tree = compile( + &eval_stmt("round(x, 1.0000006850274261 ^ 9.80019037e8)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| { + panic!("`round(x, 1.0000006850274261 ^ 9.80019037e8)` was refused: {error}") + }); + let MathOp::Round { nearest } = map_op(tree.root()) else { + panic!( + "the root is not a round: {:?}", + map_parts(tree.root()).1.ops + ); + }; + assert_eq!( + nearest, 3.6234491766672406e291, + "`1.0000006850274261 ^ 9.80019037e8` did not fold with `go_math`'s power" + ); +} + +/// A math function is pointwise over a vector, so an argument that is not one is refused and the +/// rejection names the argument rather than the function alone. The parser refuses `abs(1)` too, so +/// the AST is edited the way the arity test edits one: a shape the parser would have caught is still +/// a shape the compiler must not compile into something else. +#[test] +fn a_math_function_over_a_non_vector_argument_is_refused_and_names_it() { + let cases = [ + // A scalar, which a pointwise function has no positions to be pointwise over. + ( + with_call_arguments("abs(x)", |args| { + *args[0] = PromExpr::NumberLiteral(NumberLiteral::new(1.0)); + }), + "a call to `abs` whose first argument is `1`, which is a scalar", + ), + // A range selector, which belongs in a window rather than under a map. + ( + with_call_arguments("abs(x)", |args| { + args[0] = a_matrix_selector(); + }), + "a call to `abs` whose first argument is `x[5m]`, which is a matrix", + ), + ]; + + for (stmt, named) in cases { + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err("a math function over a non-vector compiled"); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "the refusal is not NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains(named), + "the refusal did not name the argument it found: {message}" + ); + assert!( + message.contains("where the function takes a vector"), + "the refusal did not say what the argument has to be: {message}" + ); + } +} diff --git a/src/query/src/query_engine.rs b/src/query/src/query_engine.rs index 51f7d7ce477..c512b9c1e5e 100644 --- a/src/query/src/query_engine.rs +++ b/src/query/src/query_engine.rs @@ -29,11 +29,9 @@ use common_function::handlers::{ FlowServiceHandlerRef, ProcedureServiceHandlerRef, TableMutationHandlerRef, }; use common_query::Output; -use common_query::request::{PreparedNamedRawSeries, RawSeriesRequest}; use datafusion::catalog::TableFunction; use datafusion::dataframe::DataFrame; use datafusion_expr::{AggregateUDF, LogicalPlan, WindowUDF}; -use datatypes::series::SeriesOrdering; pub use default_serializer::{DefaultPlanDecoder, DefaultSerializer}; use partition::manager::PartitionRuleManagerRef; use session::context::QueryContextRef; @@ -79,44 +77,6 @@ pub trait QueryEngine: Send + Sync { /// Execute the given [`LogicalPlan`]. async fn execute(&self, plan: LogicalPlan, query_ctx: QueryContextRef) -> Result; - /// Non-consumingly checks whether a native single-region raw-series scan is supported. - /// Returns its ordering on support, `None` when explicitly unsupported, and propagates errors. - /// This point-in-time check does not reserve a plan; execution revalidates support. - async fn prepare_raw_series( - &self, - _plan: LogicalPlan, - _query_ctx: QueryContextRef, - _binding: RawSeriesRequest, - ) -> Result> { - Ok(None) - } - - /// Non-consumingly checks whether a named raw-series preflight is supported. - /// - /// Returns the approved binding and ordering on support, or `None` when the plan is - /// explicitly unsupported. Errors propagate unchanged. This point-in-time check does not - /// reserve a plan; execution revalidates support. - async fn prepare_named_raw_series( - &self, - _plan: LogicalPlan, - _query_ctx: QueryContextRef, - ) -> Result> { - Ok(None) - } - - /// Execute a native single-region raw-series scan. - async fn execute_raw_series( - &self, - _plan: LogicalPlan, - _query_ctx: QueryContextRef, - _binding: RawSeriesRequest, - ) -> Result { - crate::error::UnimplementedSnafu { - operation: "native raw-series query", - } - .fail() - } - /// Register an aggregate function. /// /// # Panics diff --git a/src/query/src/region_query.rs b/src/query/src/region_query.rs index 96c843893bb..4d2dfb68d60 100644 --- a/src/query/src/region_query.rs +++ b/src/query/src/region_query.rs @@ -18,7 +18,7 @@ use api::v1::region::{RemoteDynFilterUnregister, RemoteDynFilterUpdate}; use async_trait::async_trait; use common_meta::node_manager::NodeManagerRef; use common_meta::peer::Peer; -use common_query::request::{PreparedNamedRawSeries, QueryRequest}; +use common_query::request::QueryRequest; use common_recordbatch::SendableRecordBatchStream; use partition::manager::PartitionRuleManagerRef; use session::ReadPreference; @@ -68,20 +68,6 @@ pub trait RegionQueryHandler: Send + Sync { request: QueryRequest, ) -> Result; - /// Non-consumingly checks whether a named raw-series preflight is supported. - /// - /// Returns the approved binding and ordering on support, or `None` when the request is - /// explicitly unsupported; failures that are not unsupported preparation are returned as - /// errors. This point-in-time check does not reserve a plan, and execution revalidates - /// support. - async fn prepare_named_raw_series_query( - &self, - _target: &RegionQueryTarget, - _request: QueryRequest, - ) -> Result> { - Ok(None) - } - async fn handle_remote_dyn_filter_update( &self, target: &RegionQueryTarget, diff --git a/src/store-api/src/region_engine.rs b/src/store-api/src/region_engine.rs index eb885a34557..10a8c3d0011 100644 --- a/src/store-api/src/region_engine.rs +++ b/src/store-api/src/region_engine.rs @@ -31,6 +31,7 @@ use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet; use datafusion_physical_plan::{DisplayAs, DisplayFormatType, PhysicalExpr}; use datatypes::schema::SchemaRef; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::LabelDecodeSpec; use datatypes::series::raw::RawSeriesChunk; use datatypes::series::samples::SeriesSchema; use futures::Stream; @@ -495,10 +496,20 @@ pub enum RawLabelSource { }, /// The stream supplies a complete metric name and label set in each chunk, not a partial /// label projection; `Some(metric)` is a complete name/label set (the name itself may be - /// absent). + /// absent), or, for a stream that has no identity at all, see + /// [`NO_BOUND_LABEL_SOURCE`]. Explicit, } +/// The identity a stream reports when no scan-bound label source was established for it. +/// +/// [`RawLabelSource`] has no "absent" variant, and [`RawLabelSource::Explicit`] is the only one that +/// carries no physical region key layout, so it stands in for "no identity" here. It is not a claim +/// that the chunks carry labels: [`RawSeriesStream::label_decode_spec`] answers that, and a stream +/// reporting this value can still return `Some` description and leave `metric` as `None` in every +/// chunk. Decoding therefore asks the description, and only an identity check asks this. +pub const NO_BOUND_LABEL_SOURCE: RawLabelSource = RawLabelSource::Explicit; + /// A stream of raw series chunks with stable schema, label binding, and ordering. /// /// Every series has strictly increasing timestamps within and across chunks; timestamps are @@ -508,8 +519,10 @@ pub enum RawLabelSource { /// per-stream guarantees, not query-global order, partition ownership, or finality. `is_last` /// marks the last chunk of that series in this stream only; merging requires recomputing /// per-input-series finalization across all related inputs, not aggregate-group finality. -/// `RawLabelSource` keeps label interpretation self-contained through transport. Stream finality -/// applies to all related input series, not aggregate groups or vector-match state. +/// Label interpretation is carried by the stream: [`RawSeriesStream::label_source`] states the +/// scan-bound identity a producer read under, and [`RawSeriesStream::label_decode_spec`] states the +/// label layout the stream's own chunks declare. Stream finality applies to all related input +/// series, not aggregate groups or vector-match state. pub trait RawSeriesStream: Stream> + Send { /// Returns the schema shared by all chunks; raw schemas have no evaluation grid. fn schema(&self) -> &Arc; @@ -517,7 +530,26 @@ pub trait RawSeriesStream: Stream> + S /// labels are undecoded, not that the metric name is missing; `Explicit` chunks supply /// `Some` with the complete name/label set, not a partial projection. The metric name itself /// may be absent. + /// + /// A stream that was decoded from a raw frame has no scan-bound identity and reports + /// [`NO_BOUND_LABEL_SOURCE`], which reads as `Explicit` while its chunks may still carry no + /// labels at all. That value therefore answers "is this the region I read under", never "are + /// these labels complete": [`Self::label_decode_spec`] is the stream's statement about its + /// encoding, and every consumer that decides whether to decode reads that instead. fn label_source(&self) -> &RawLabelSource; + /// Returns the label decode description this stream declares for key-encoded labels. + /// + /// Every stream whose chunks carry key-encoded labels declares the description here, whether + /// it is a storage producer or a stream reassembled from a raw frame. A consumer holding only + /// that description can therefore decode labels without a scan-bound [`RawLabelSource`]: the + /// description, not the binding, describes the encoding, and a receiver reporting + /// [`NO_BOUND_LABEL_SOURCE`] still states through this description that its chunks are + /// undecoded key chunks. A stream that carries complete labels instead answers `None`. This is + /// the description the stream declares, not an authenticated statement about where the values + /// came from. + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + None + } /// Returns this stream's ordering guarantee. fn ordering(&self) -> SeriesOrdering; } diff --git a/src/store-api/src/region_engine/prepared_series_tests.rs b/src/store-api/src/region_engine/prepared_series_tests.rs index b44b543a3ef..d3835f5d0b1 100644 --- a/src/store-api/src/region_engine/prepared_series_tests.rs +++ b/src/store-api/src/region_engine/prepared_series_tests.rs @@ -26,11 +26,15 @@ use datatypes::error::InvalidSeriesSnafu; use datatypes::prelude::ConcreteDataType; use datatypes::schema::ColumnSchema; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::{ + LABEL_DECODE_VERSION, LabelDecodeColumn, LabelDecodeEncoding, LabelDecodeSpec, LabelDecodeTag, +}; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{FieldSpec, Samples, SeriesFields, SeriesSchema}; use datatypes::series::{Label, MetricName}; use futures::{Stream, StreamExt}; +use crate::codec::PrimaryKeyEncoding; use crate::region_engine::{ PreparedSeriesSource, RawLabelSource, RawSeriesStream, SendableRawSeriesStream, SeriesInfo, }; @@ -91,6 +95,7 @@ impl PreparedSeriesSource for MemorySource { Ok(Box::pin(MemoryReader { chunks, schema: self.schema.clone(), + label_decode_spec: declared_decode_spec(&self.label_source), label_source: self.label_source.clone(), polls: self.polls.clone(), drops: self.drops.clone(), @@ -101,6 +106,8 @@ impl PreparedSeriesSource for MemorySource { struct MemoryReader { chunks: VecDeque, schema: Arc, + /// The description this fixture declares, derived once from `label_source`, as a producer does. + label_decode_spec: Option, label_source: RawLabelSource, polls: Arc, drops: Arc, @@ -133,11 +140,58 @@ impl RawSeriesStream for MemoryReader { &self.label_source } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.label_decode_spec.as_ref() + } + fn ordering(&self) -> SeriesOrdering { SeriesOrdering::Contiguous } } +/// Describes the key-encoded chunks a reader of `source` yields. +/// +/// This crate cannot depend on `common-query` (which depends on it), so this fixture repeats the +/// derivation `common_query::series_frame::label_decode_spec` performs for a producer: the metric +/// name, the primary-key encoding, the ordered key columns with their physical types, and the +/// logical tags in canonical order. An explicit binding already carries complete labels and +/// declares nothing. +fn declared_decode_spec(source: &RawLabelSource) -> Option { + let RawLabelSource::PrimaryKey { + metric_name, + physical_metadata, + logical_tags, + } = source + else { + return None; + }; + let mut description = LabelDecodeSpec { + version: LABEL_DECODE_VERSION, + metric_name: metric_name.to_string(), + encoding: match physical_metadata.primary_key_encoding { + PrimaryKeyEncoding::Dense => LabelDecodeEncoding::Dense, + PrimaryKeyEncoding::Sparse => LabelDecodeEncoding::Sparse, + }, + primary_key: physical_metadata + .primary_key_columns() + .map(|column| LabelDecodeColumn { + column_id: column.column_id, + name: column.column_schema.name.clone(), + physical_type: column.column_schema.data_type.clone(), + }) + .collect(), + logical_tags: logical_tags + .iter() + .map(|tag| LabelDecodeTag { + column_id: tag.column_id, + name: tag.name.to_string(), + }) + .collect(), + }; + description.canonicalize(); + Some(description) +} + fn metric(host: &str) -> MetricName { MetricName::try_new( Some(Arc::from("cpu")), @@ -358,6 +412,25 @@ async fn prepared_source_label_binding_is_available_before_readers_and_matches_c let reader = source.read_series(0..2).unwrap(); assert_same_label_source(source.label_source(), reader.label_source()); + match (explicit, reader.label_decode_spec()) { + (true, None) => {} + (false, Some(description)) => { + assert_eq!(description.version, LABEL_DECODE_VERSION); + assert_eq!(description.metric_name, "cpu"); + assert_eq!(description.encoding, LabelDecodeEncoding::Dense); + assert_eq!(description.primary_key.len(), 1); + assert_eq!(description.primary_key[0].column_id, 1); + assert_eq!(description.primary_key[0].name, "host"); + assert_eq!( + description.primary_key[0].physical_type, + ConcreteDataType::string_datatype() + ); + assert_eq!(description.logical_tags.len(), 1); + assert_eq!(description.logical_tags[0].column_id, 1); + assert_eq!(description.logical_tags[0].name, "host"); + } + _ => panic!("unexpected declared label decode description"), + } assert_eq!(source.polls.load(Ordering::SeqCst), 0); let chunks = futures::TryStreamExt::try_collect::>(reader) .await diff --git a/src/table/src/table/scan.rs b/src/table/src/table/scan.rs index d6ac26c0a21..486b1e8d6da 100644 --- a/src/table/src/table/scan.rs +++ b/src/table/src/table/scan.rs @@ -57,7 +57,6 @@ use datafusion_physical_expr::{ use datatypes::arrow::datatypes::SchemaRef as ArrowSchemaRef; use datatypes::compute::SortOptions; use datatypes::series::SeriesOrdering; -use datatypes::series::frame::RawLabelMode; use datatypes::series::samples::SeriesSchema; use futures::{Stream, StreamExt}; use store_api::metric_engine_consts::DATA_SCHEMA_TSID_COLUMN_NAME; @@ -593,11 +592,7 @@ impl RegionScanExec { return Ok(None); }; let ordering = scanner.raw_series_ordering(); - let arrow_schema = raw_series_frame_schema_with_ordering( - &schema, - raw_label_mode(&label_source), - ordering, - )?; + let arrow_schema = raw_series_frame_schema_with_ordering(&schema, &label_source, ordering)?; let partition_count = scanner.properties().num_partitions().max(1); let properties = Arc::new(PlanProperties::new( EquivalenceProperties::new(arrow_schema.clone()), @@ -667,13 +662,6 @@ impl SeriesScanExec { } } -fn raw_label_mode(source: &RawLabelSource) -> RawLabelMode { - match source { - RawLabelSource::PrimaryKey { .. } => RawLabelMode::Key, - RawLabelSource::Explicit => RawLabelMode::Explicit, - } -} - fn same_label_source(left: &RawLabelSource, right: &RawLabelSource) -> bool { match (left, right) { (RawLabelSource::Explicit, RawLabelSource::Explicit) => true, @@ -785,11 +773,8 @@ impl ExecutionPlan for SeriesScanExec { DataFusionError::Execution("raw series capability changed after planning".into()) })?; let current_ordering = scanner.raw_series_ordering(); - let current_frame = raw_series_frame_schema_with_ordering( - ¤t, - raw_label_mode(&self.label_source), - current_ordering, - )?; + let current_frame = + raw_series_frame_schema_with_ordering(¤t, &self.label_source, current_ordering)?; if current_frame != self.arrow_schema || scanner.properties().num_partitions().max(1) != partitions { diff --git a/src/table/src/table/scan/series_tests.rs b/src/table/src/table/scan/series_tests.rs index 19209a50af3..465cd805a79 100644 --- a/src/table/src/table/scan/series_tests.rs +++ b/src/table/src/table/scan/series_tests.rs @@ -36,6 +36,7 @@ use datatypes::data_type::ConcreteDataType; use datatypes::error::InvalidSeriesSnafu; use datatypes::schema::{ColumnSchema, Schema, SchemaRef}; use datatypes::series::SeriesOrdering; +use datatypes::series::frame::{LabelDecodeSpec, RawLabelMode}; use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; use datatypes::series::samples::{FieldSpec, Samples, SeriesFields, SeriesSchema}; use datatypes::vectors::{Int32Vector, TimestampMillisecondVector, VectorRef}; @@ -82,6 +83,8 @@ fn raw_error(reason: &str) -> BoxedError { struct RawStream { schema: Arc, source: RawLabelSource, + /// The description this fixture declares, derived once from `source`, as a producer does. + label_decode_spec: Option, chunks: Vec, state: Arc>, fail_first_poll: bool, @@ -126,6 +129,9 @@ impl RawSeriesStream for RawStream { fn label_source(&self) -> &RawLabelSource { &self.source } + fn label_decode_spec(&self) -> Option<&LabelDecodeSpec> { + self.label_decode_spec.as_ref() + } fn ordering(&self) -> SeriesOrdering { self.ordering } @@ -211,16 +217,18 @@ impl RegionScanner for TestScanner { return Ok(None); } let ordering = state.stream_ordering.unwrap_or(SeriesOrdering::KeyThenTime); + let source = state + .stream_source + .clone() + .unwrap_or_else(|| source.clone()); Ok(Some(Box::pin(RawStream { ordering, schema: state .stream_schema .clone() .unwrap_or_else(|| self.raw_schema.clone().unwrap()), - source: state - .stream_source - .clone() - .unwrap_or_else(|| source.clone()), + label_decode_spec: common_query::series_frame::label_decode_spec(&source), + source, chunks: self.chunks.clone(), state: self.state.clone(), fail_first_poll: state.fail_first_poll, @@ -723,7 +731,7 @@ async fn supported_empty_stream_remains_native_with_declared_frame_schema() { plan.schema(), raw_series_frame_schema_with_ordering( plan.series_schema(), - RawLabelMode::Explicit, + &RawLabelSource::Explicit, SeriesOrdering::KeyThenTime ) .unwrap() diff --git a/tests/compatibility/fixtures/series_index/README.md b/tests/compatibility/fixtures/series_index/README.md new file mode 100644 index 00000000000..29c3000f838 --- /dev/null +++ b/tests/compatibility/fixtures/series_index/README.md @@ -0,0 +1,10 @@ +# Series-index catalog compatibility + +`legacy_catalog.json` is a synthetic fixture matching `SeriesIndexEntry` at +`3ad5f963285e51166fd8789920e4b3bf2cf60423`, before optional artifact metadata. +It is not captured from a released binary. Its entry must remain a reusable +Parquet index and serialize without adding an artifact field. + +Run `cargo nextest run -p mito2 -E 'test(legacy_series_catalog_fixture)'`. +The owning test also checks the persisted artifact format names. Ordinary +index-reader tests exercise Parquet files without a full-primary-key column. diff --git a/tests/compatibility/fixtures/series_index/legacy_catalog.json b/tests/compatibility/fixtures/series_index/legacy_catalog.json new file mode 100644 index 00000000000..e119e602a8d --- /dev/null +++ b/tests/compatibility/fixtures/series_index/legacy_catalog.json @@ -0,0 +1,27 @@ +{ + "indexes": [ + { + "file_size": 123, + "index_uuid": "00000000-0000-0000-0000-000000000001", + "bucket_start": { + "value": 0, + "unit": "Second" + }, + "bucket_end": { + "value": 100, + "unit": "Second" + }, + "source_file_ids": [], + "min_file_sequence": 1, + "max_file_sequence": 2, + "compaction_window_secs": 100, + "window_sequences": { + "0": { + "start": 0, + "end": 100, + "max_sequence": 2 + } + } + } + ] +} diff --git a/tests/compatibility/wire/named_raw_series_prepare/README.md b/tests/compatibility/wire/named_raw_series_prepare/README.md deleted file mode 100644 index 0175bdbeb3d..00000000000 --- a/tests/compatibility/wire/named_raw_series_prepare/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Named raw-series preflight contract v1 - -`v1_supported.json` and `v1_unsupported.json` are hand-authored, frozen JSON -bodies of the named raw-series preflight action -`greptime.prepare_raw_series.v1` (`PREPARE_RAW_SERIES_ACTION`). - -- `v1_supported.json` approves the request with the `contiguous` ordering. Its - nested `binding` value is the same version-1 request frozen in - [`../raw_series_request/v1_request.json`](../raw_series_request/v1_request.json) - (identical JSON text; the envelope embeds it without the source file's - trailing newline). -- `v1_unsupported.json` is the frozen unsupported shape, exactly - `{"version":1,"supported":null}`: the version-1 envelope with an explicit - `null` `supported` field. - -The envelope requires `version` (only `1` is supported) and a present-but-nullable -`supported` field. An approving `supported` object rejects unknown fields and -carries exactly the existing raw-series request plus an ordering of `contiguous` -or `read_key_then_time`; `interleaved`, unknown or missing orderings, duplicate -keys, unknown fields, and malformed bindings are rejected. The nested request -keeps its existing deserializer and validation rules; the nested physical -metadata is not checked for unknown fields by this contract. - -Run the focused check: - -```shell -cargo nextest run -p common-query named_raw_series -``` - -The fixture proves the version-1 envelope decodes as written and that the -unsupported shape is frozen. It does not prove interoperability with an old -binary; it records the contract this revision writes and reads. diff --git a/tests/compatibility/wire/named_raw_series_prepare/v1_supported.json b/tests/compatibility/wire/named_raw_series_prepare/v1_supported.json deleted file mode 100644 index e201cebe3b0..00000000000 --- a/tests/compatibility/wire/named_raw_series_prepare/v1_supported.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"supported":{"binding":{"version":1,"physical_metadata":{"column_metadatas":[{"column_schema":{"name":"host","data_type":{"Int64":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Tag","column_id":1},{"column_schema":{"name":"value","data_type":{"Int32":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Field","column_id":2},{"column_schema":{"name":"aux","data_type":{"Int64":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Field","column_id":3},{"column_schema":{"name":"ts","data_type":{"Timestamp":{"Millisecond":null}},"is_nullable":false,"is_time_index":true,"default_constraint":null,"metadata":{}},"semantic_type":"Timestamp","column_id":4}],"primary_key":[1],"region_id":4294967297,"schema_version":0,"primary_key_encoding":"dense"},"metric_name":"cpu","logical_tags":[{"column_id":1,"name":"host"}],"field_ids":[3,2]},"ordering":"contiguous"}} diff --git a/tests/compatibility/wire/named_raw_series_prepare/v1_unsupported.json b/tests/compatibility/wire/named_raw_series_prepare/v1_unsupported.json deleted file mode 100644 index cd2aeccb8a6..00000000000 --- a/tests/compatibility/wire/named_raw_series_prepare/v1_unsupported.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"supported":null} diff --git a/tests/compatibility/wire/raw_series_label_decode/README.md b/tests/compatibility/wire/raw_series_label_decode/README.md new file mode 100644 index 00000000000..a7d5c36858f --- /dev/null +++ b/tests/compatibility/wire/raw_series_label_decode/README.md @@ -0,0 +1,75 @@ +# Raw-series label decode description compatibility + +`v1_metadata.json` is a hand-authored, frozen set of Arrow schema metadata maps +for the v1 raw-series frame. It records the established layout/content/labels/ +time metadata plus the new `greptime.series.label_decode` entry, whose value is a +JSON label decode description with an explicit `version`. The Rust unit test +parses these literal maps through `raw_series_frame_label_decode`; the fixture is +not produced by the schema builder. `v1_physical_types.json`, described under +[Physical types](#physical-types), is the one generated fixture here: it records +the serde text of every `ConcreteDataType` variant. + +Run the focused check: + +```shell +cargo nextest run -p datatypes -E 'test(raw_series_label_decode_metadata_fixture)' +``` + +The fixture proves that a key-encoded frame's description decodes for both +primary-key encodings, that the ordered key columns and logical tags survive the +wire form with a mixed physical type set, that a logical tag must name a listed key +column, and that the reader rejects a key frame without a description, an explicit +frame that declares one, an unknown version, a missing or unknown field, a wrong +field type, and a non-JSON value. + +A timestamp-typed tag column has its own case in the crate's unit tests rather +than here, so this fixture stays a single fixed layout. + +The fixture does not prove interoperability with an old binary, nor does it test +an end-to-end client or service. Separate pack and IPC tests check that a packed +stream declares the description derived from its binding and that it agrees with +the schema a consumer receives. + +`greptime.series.*` metadata is compared as exact text by remote merges, so the +text stored here is what the single writer produces: compact JSON in declaration +order. A reader must never regenerate it differently; semantically equal text with +different whitespace is a mismatch, not an accepted variant. + +The description is versioned independently of the surrounding frame layout: a +reader rejects any version it does not implement instead of guessing, so a future +revision must add a new fixture case rather than relax this one. + +## Physical types + +`physical_type` is `ConcreteDataType`'s own serde form, so this description's +text embeds the serialization of a type defined in `src/datatypes/src/data_type.rs` +and its `types/` modules. That shape is not restated or owned here: whatever +`ConcreteDataType`'s serde writes is what a description carries, and the two +spellings that appear below — `{"String":{"size_type":"Utf8"}}` and +`{"UInt32":{}}` — are examples of it rather than a vocabulary this fixture +defines. + +What freezes that shape is `v1_physical_types.json`: one entry per +`ConcreteDataType` variant, `{"variant": "", "text": ""}`, with +`text` generated from `serde_json::to_string` rather than written by hand. The +crate's `every_datatype_round_trips_through_its_frozen_text` test fails when a +variant's serde no longer parses back or no longer re-serializes to the frozen +text, and `the_frozen_text_covers_every_datatype_variant` fails when a variant of +`ConcreteDataType` has no entry, because its exhaustive `match` names every +variant. Changing `ConcreteDataType`'s serde, or any `text` recorded here, changes +the text two nodes compare, so it requires bumping `LABEL_DECODE_VERSION` and +regenerating this fixture together. + +`logical_tags` is written in the writer's canonical order (sorted by +`(column_id, name)`). The order carries no meaning — the decoder sorts tags by +name when it binds them — but fixing it means two producers of the same labels +write the same bytes, which is what lets the same equality rule serve both a +local comparison and the byte-exact remote one. The writer canonicalizes a copy +of the description for exactly that reason, leaving the caller's binding order +alone. + +Run the physical-type check too: + +```shell +cargo nextest run -p datatypes -E 'test(datatype) or test(frozen_text)' +``` diff --git a/tests/compatibility/wire/raw_series_label_decode/v1_metadata.json b/tests/compatibility/wire/raw_series_label_decode/v1_metadata.json new file mode 100644 index 00000000000..2e4e6d7f24b --- /dev/null +++ b/tests/compatibility/wire/raw_series_label_decode/v1_metadata.json @@ -0,0 +1,111 @@ +[ + { + "name": "key_frame_declares_dense_description", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": "dense" + }, + { + "name": "key_frame_declares_sparse_description", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"metric_name\":\"cpu_usage\",\"encoding\":\"sparse\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": "sparse" + }, + { + "name": "missing_description_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps" + }, + "encoding": null + }, + { + "name": "unknown_version_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":2,\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": null + }, + { + "name": "missing_field_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": null + }, + { + "name": "unknown_field_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}],\"future\":0}" + }, + "encoding": null + }, + { + "name": "wrong_field_type_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":\"1\",\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": null + }, + { + "name": "unlisted_logical_tag_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": null + }, + { + "name": "explicit_frame_with_description_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "explicit", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "{\"version\":1,\"metric_name\":\"cpu_usage\",\"encoding\":\"dense\",\"primary_key\":[{\"column_id\":1,\"name\":\"host\",\"physical_type\":{\"String\":{\"size_type\":\"Utf8\"}}},{\"column_id\":2,\"name\":\"shard\",\"physical_type\":{\"UInt32\":{}}}],\"logical_tags\":[{\"column_id\":1,\"name\":\"host\"},{\"column_id\":2,\"name\":\"shard\"}]}" + }, + "encoding": null + }, + { + "name": "malformed_json_rejected", + "metadata": { + "greptime.series.layout": "v1", + "greptime.series.content": "raw", + "greptime.series.labels": "key", + "greptime.series.time": "timestamps", + "greptime.series.label_decode": "greptime" + }, + "encoding": null + } +] diff --git a/tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json b/tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json new file mode 100644 index 00000000000..e5f8f6a2d95 --- /dev/null +++ b/tests/compatibility/wire/raw_series_label_decode/v1_physical_types.json @@ -0,0 +1,102 @@ +[ + { + "variant": "Null", + "text": "{\"Null\":null}" + }, + { + "variant": "Boolean", + "text": "{\"Boolean\":null}" + }, + { + "variant": "Int8", + "text": "{\"Int8\":{}}" + }, + { + "variant": "Int16", + "text": "{\"Int16\":{}}" + }, + { + "variant": "Int32", + "text": "{\"Int32\":{}}" + }, + { + "variant": "Int64", + "text": "{\"Int64\":{}}" + }, + { + "variant": "UInt8", + "text": "{\"UInt8\":{}}" + }, + { + "variant": "UInt16", + "text": "{\"UInt16\":{}}" + }, + { + "variant": "UInt32", + "text": "{\"UInt32\":{}}" + }, + { + "variant": "UInt64", + "text": "{\"UInt64\":{}}" + }, + { + "variant": "Float32", + "text": "{\"Float32\":{}}" + }, + { + "variant": "Float64", + "text": "{\"Float64\":{}}" + }, + { + "variant": "Decimal128", + "text": "{\"Decimal128\":{\"precision\":10,\"scale\":2}}" + }, + { + "variant": "Binary", + "text": "{\"Binary\":{\"repr_type\":\"Binary\"}}" + }, + { + "variant": "String", + "text": "{\"String\":{\"size_type\":\"Utf8\"}}" + }, + { + "variant": "Date", + "text": "{\"Date\":null}" + }, + { + "variant": "Timestamp", + "text": "{\"Timestamp\":{\"Millisecond\":null}}" + }, + { + "variant": "Time", + "text": "{\"Time\":{\"Microsecond\":null}}" + }, + { + "variant": "Duration", + "text": "{\"Duration\":{\"Nanosecond\":null}}" + }, + { + "variant": "Interval", + "text": "{\"Interval\":{\"MonthDayNano\":null}}" + }, + { + "variant": "List", + "text": "{\"List\":{\"item_type\":{\"String\":{\"size_type\":\"Utf8\"}}}}" + }, + { + "variant": "Dictionary", + "text": "{\"Dictionary\":{\"key_type\":{\"Int32\":{}},\"value_type\":{\"String\":{\"size_type\":\"Utf8\"}}}}" + }, + { + "variant": "Struct", + "text": "{\"Struct\":{\"fields\":[{\"name\":\"host\",\"data_type\":{\"String\":{\"size_type\":\"Utf8\"}},\"nullable\":false,\"metadata\":{}},{\"name\":\"port\",\"data_type\":{\"UInt16\":{}},\"nullable\":true,\"metadata\":{}}]}}" + }, + { + "variant": "Json", + "text": "{\"Json\":{\"format\":\"Jsonb\"}}" + }, + { + "variant": "Vector", + "text": "{\"Vector\":{\"dim\":3}}" + } +] diff --git a/tests/compatibility/wire/raw_series_request/README.md b/tests/compatibility/wire/raw_series_request/README.md deleted file mode 100644 index 30d3631a36d..00000000000 --- a/tests/compatibility/wire/raw_series_request/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Raw-series request control v1 - -`v1_request.json` is a hand-authored frozen JSON example consumed by the common-query request tests. The version-1 envelope and logical-tag objects reject unknown fields, and unsupported control versions are rejected. Physical metadata keeps its existing deserializer and compatibility rules. A legacy request without the reserved marker remains an ordinary request. A new client talking to an old server may receive an ordinary-schema response, which the raw unpacker rejects; this fixture does not claim cross-version raw execution support. diff --git a/tests/compatibility/wire/raw_series_request/v1_request.json b/tests/compatibility/wire/raw_series_request/v1_request.json deleted file mode 100644 index 1ff6446db3d..00000000000 --- a/tests/compatibility/wire/raw_series_request/v1_request.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"physical_metadata":{"column_metadatas":[{"column_schema":{"name":"host","data_type":{"Int64":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Tag","column_id":1},{"column_schema":{"name":"value","data_type":{"Int32":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Field","column_id":2},{"column_schema":{"name":"aux","data_type":{"Int64":{}},"is_nullable":false,"is_time_index":false,"default_constraint":null,"metadata":{}},"semantic_type":"Field","column_id":3},{"column_schema":{"name":"ts","data_type":{"Timestamp":{"Millisecond":null}},"is_nullable":false,"is_time_index":true,"default_constraint":null,"metadata":{}},"semantic_type":"Timestamp","column_id":4}],"primary_key":[1],"region_id":4294967297,"schema_version":0,"primary_key_encoding":"dense"},"metric_name":"cpu","logical_tags":[{"column_id":1,"name":"host"}],"field_ids":[3,2]}