chore: merge feat/narthex-agg-stat into feat/narthex-agg-state

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
Dennis Zhuang
2026-10-21 19:57:37 +08:00
80 changed files with 12687 additions and 1672 deletions
+1
View File
@@ -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)
+2 -118
View File
@@ -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<SendableRecordBatchStream>;
/// 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<Option<SeriesOrdering>> {
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<Option<PreparedNamedRawSeries>> {
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<SendableRawSeriesStream> {
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<SeriesSchema>, 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 + '_ {
+1 -8
View File
@@ -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;
@@ -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<Vec<u8>> {
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<Option<PreparedNamedRawSeries>> {
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<Supported>,
}
/// 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<Self> {
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<Option<Supported>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Supported>::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::<Vec<_>>(),
["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());
}
}
@@ -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<String> {
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<Self> {
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<RegionRequestHeader>,
) -> DataFusionResult<Option<RawSeriesRequest>> {
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]
+218 -31
View File
@@ -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<ArrowSchemaRef> {
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<ArrowSchemaRef> {
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<LabelDecodeSpec> {
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<DfSendableRecordBatchStream> {
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<SeriesSchema>,
label_source: RawLabelSource,
ordering: SeriesOrdering,
expected: Option<Arc<SeriesSchema>>,
) -> datafusion_common::Result<SendableRawSeriesStream> {
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<LabelDecodeSpec>) -> 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<DfSendableRecordBatchStream>,
schema: Arc<SeriesSchema>,
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<LabelDecodeSpec>,
ordering: SeriesOrdering,
validator: SequenceValidator,
pending: std::vec::IntoIter<RawSeriesChunk>,
@@ -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 {
@@ -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]);
File diff suppressed because it is too large Load Diff
@@ -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());
+327 -3
View File
@@ -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<LabelDecodeColumn>,
/// 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<LabelDecodeTag>,
}
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<SchemaRef> {
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<SchemaRef> {
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<Option<LabelDecodeSpec>> {
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<SeriesSchema> {
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");
+527
View File
@@ -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<String, String>,
/// The encoding a decoding consumer expects, or `null` when the fixture must be rejected.
encoding: Option<String>,
}
let cases: Vec<Case> = 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<ConcreteDataType> {
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<FrozenPhysicalType> {
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"
);
}
+10
View File
@@ -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<datatypes::error::Error>,
#[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
}
+156 -43
View File
@@ -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<str>,
physical_metadata: RegionMetadataRef,
encoding: PrimaryKeyEncoding,
columns: Vec<KeyColumn>,
}
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<usize> {
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<BoundLabel>,
@@ -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<Self> {
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<Self> {
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<Self>
where
I: IntoIterator<Item = (ColumnId, &'a str)>,
{
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));
}
+111 -2
View File
@@ -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<datatypes::series::samples::SeriesSchema> {
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::<str>::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}");
}
+2 -1
View File
@@ -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`.
+227 -25
View File
@@ -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::<Vec<_>>().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::<Vec<_>>()
.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::<Vec<_>>().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::<Vec<_>>().await.unwrap();
assert_eq!(
stream_rows,
@@ -6199,6 +6187,8 @@ fn primary_key_label_source(
struct ChainedRawSeriesStream {
schema: Arc<datatypes::series::samples::SeriesSchema>,
label_source: store_api::region_engine::RawLabelSource,
/// The description this stream declares, derived from `label_source` once, like a producer.
label_decode_spec: Option<datatypes::series::frame::LabelDecodeSpec>,
ordering: datatypes::series::SeriesOrdering,
chunks: std::vec::IntoIter<datatypes::series::raw::RawSeriesChunk>,
}
@@ -6210,9 +6200,11 @@ impl ChainedRawSeriesStream {
ordering: datatypes::series::SeriesOrdering,
chunks: Vec<datatypes::series::raw::RawSeriesChunk>,
) -> 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<datatypes::series::MetricName> {
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::<Vec<_>>().await.unwrap());
}
assert_eq!(
2,
chunks.iter().map(|chunk| chunk.time.len()).sum::<usize>()
);
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::<Vec<_>>().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::<Vec<_>>().await.unwrap());
}
assert_eq!(
2,
chunks.iter().map(|chunk| chunk.time.len()).sum::<usize>()
);
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::<Vec<_>>()
.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::<Vec<_>>()
.await
+47
View File
@@ -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::<Vec<_>>(),
)
.await
.expect("index streams must not hold permits across yield")
.unwrap();
assert_eq!(
(0..1001)
.map(|tsid| MetricSeriesId { table_id: 1, tsid })
.collect::<Vec<_>>(),
batches.into_iter().flatten().collect::<Vec<_>>()
);
assert_eq!(1, semaphore.available_permits());
}
#[tokio::test]
async fn index_read_failure_after_output_is_propagated() {
let (_env, scanner, _pruner) = indexed_scanner().await;
+16
View File
@@ -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<SeriesSchema>,
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<LabelDecodeSpec>,
ordering: SeriesOrdering,
inner: BoxStream<'static, Result<RawSeriesChunk, BoxedError>>,
}
@@ -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<RawSeriesChunk> = None;
@@ -267,6 +282,7 @@ pub(crate) fn make_stream(
Box::pin(RawStream {
schema,
label_source,
label_decode_spec,
ordering,
inner: Box::pin(stream),
})
+4
View File
@@ -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;
+93
View File
@@ -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<String>,
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<SamplesFileInner>);
#[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());
}
}
}
+1
View File
@@ -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,
+110 -4
View File
@@ -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<SeriesIndexFileHandle> {
let mut sources = Vec::<BoxedRecordBatchStream>::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<Option<crate::series_index::artifact::SamplesFileHandle>> {
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, &region, &version, &bucket, &entry, &purger)
let _handle = build_series_index(&store, &region, &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, &region, &version, &bucket, &entry, &purger)
build_series_index(&store, &region, &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, &region, &version, &bucket, &entry, &purger)
build_series_index(&store, &region, &version, &bucket, &entry, None, &purger)
.await
.map(|_| ())
};
+71 -5
View File
@@ -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<crate::series_index::artifact::ArtifactMetadata>,
/// 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<RangeIndexEntry>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct SamplesCatalog {
pub(crate) entries: Vec<crate::series_index::artifact::SamplesEntry>,
/// Once samples are enabled this single catalog commits all artifact references together.
pub(crate) series: Vec<SeriesIndexEntry>,
pub(crate) ranges: Vec<RangeIndexEntry>,
}
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::<SeriesIndexCatalog>(store, &series_catalog_path(region_id))
.await
.unwrap_or_default();
let version = SeriesIndexVersion::new(
range
.indexes
let samples = load_catalog::<SamplesCatalog>(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::<serde_json::Value>(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),
+72
View File
@@ -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<Self>) -> Result<ArtifactMetadata>;
async fn abort(self: Box<Self>) -> 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<Box<dyn FormatWriter>>;
}
/// 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<Box<dyn FormatWriter>>;
}
/// 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<Arc<dyn SeriesIndexFormat>>,
pub(crate) samples: Option<Arc<dyn SamplesFormat>>,
}
+442
View File
@@ -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<AtomicBool>,
}
struct FakeWriter {
store: ObjectStore,
artifact: ArtifactMetadata,
fail: Arc<AtomicBool>,
}
#[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<Self>) -> Result<ArtifactMetadata> {
Ok(self.artifact)
}
async fn abort(self: Box<Self>) -> 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<Box<dyn FormatWriter>> {
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, &region, &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<Box<dyn FormatWriter>> {
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,
&timestamps,
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::<Vec<_>>()
.await
.unwrap();
assert_eq!(
timestamps.len(),
batches.iter().map(|batch| batch.num_rows()).sum::<usize>()
);
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<mock::Metadata> {
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, &region.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();
}
+141 -10
View File
@@ -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<SeriesIndexFileHandle>);
struct UnpublishedSeriesFiles(
Vec<SeriesIndexFileHandle>,
Vec<crate::series_index::artifact::SamplesFileHandle>,
);
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(&current, &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<SeriesIndexVersion> {
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::<Vec<_>>();
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<SeriesIndexVersion>) {
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),
+35 -14
View File
@@ -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<Vec<String>>,
}
#[derive(Clone)]
@@ -57,6 +58,13 @@ impl Debug for IndexFilePurger {
}
impl IndexFilePurger {
pub(crate) fn purge_objects(&self, file_id: RegionFileId, objects: Vec<String>) {
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;
+1
View File
@@ -312,6 +312,7 @@ mod tests {
) -> (SeriesIndexFileHandle, UnboundedReceiver<PurgeRequest>) {
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),
+3 -2
View File
@@ -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, &region, &version, bucket, entry, purger)
let handle = build_series_index(store, &region, &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),
+22 -5
View File
@@ -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<FileId, crate::series_index::artifact::SamplesFileHandle>,
pub(crate) range_indexes: HashMap<FileId, RangeIndexEntry>,
pub(crate) series_indexes: HashMap<FileId, SeriesIndexFileHandle>,
pub(crate) index_buckets: BTreeMap<Timestamp, IndexBucket>,
@@ -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::<u64>()
+ self
.range_indexes
.values()
.map(|entry| entry.file_size)
.sum::<u64>()
+ 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<Arc<SeriesIndexVersion>>,
pub(crate) formats: RwLock<Arc<crate::series_index::format::SeriesFormats>>,
}
impl SeriesIndexVersionControl {
+110
View File
@@ -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 <case> # 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.
@@ -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<RecordBatch> {
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::<i64>(
@@ -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::<u64>());
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::<ArrayRef>()),
)
}
/// 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<usize> {
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::<StringArray>() {
return Some(values.offsets().lengths().max().unwrap_or(0));
}
if let Some(values) = column.as_any().downcast_ref::<LargeStringArray>() {
return Some(values.offsets().lengths().max().unwrap_or(0));
}
let values = column.as_any().downcast_ref::<StructArray>()?;
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::<i32>().saturating_add(widest_list_row(child))
}
DataType::LargeList(_) => {
std::mem::size_of::<i64>().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::<f64>()),
})
.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::<ListArray>() {
(list.offsets().lengths().max().unwrap_or(0), list.values())
} else if let Some(list) = column.as_any().downcast_ref::<LargeListArray>() {
(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::<f64>()),
)
}
fn reuse_constant_column(array: &Arc<dyn Array>, len: usize) -> DataFusionResult<Arc<dyn Array>> {
@@ -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<dyn MemoryPool> = 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<TaskContext> {
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<InstantManipulateExec> {
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<Millisecond>, Vec<f64>) = 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<InstantManipulateExec> {
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<Millisecond> = batches
.iter()
.flat_map(|batch| {
batch
.column(0)
.as_any()
.downcast_ref::<TimestampMillisecondArray>()
.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<Millisecond>| {
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::<ArrayRef>()
);
// 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::<ArrayRef>()
);
// 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::<ArrayRef>()
);
}
@@ -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);
@@ -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<usize> {
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())
})
@@ -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<SeriesSchema>,
label_source: RawLabelSource,
ordering: SeriesOrdering,
properties: Arc<PlanProperties>,
}
impl NativeInstantManipulateExec {
@@ -124,7 +121,6 @@ impl NativeInstantManipulateExec {
raw_schema: SchemaRef,
series_schema: Arc<SeriesSchema>,
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::<Vec<Samples>>())?)
})
.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
@@ -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<dyn MemoryPool> = 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(),
}
}
+256 -82
View File
@@ -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<SeriesSchema>, series: Vec<Series>) -> Result<Vec<Series>> {
let mut groups: HashMap<LabelSet, Vec<usize>> = HashMap::with_capacity(series.len());
let mut collided = false;
@@ -339,111 +347,277 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> 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<Option<Series>> = 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<usize>)> = 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<usize> = 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<RowCursor<'_>> = 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<FieldReader<'_>> =
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<Vec<RowCursor<'_>>> = 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<FieldReader<'_>> = 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<f64>]) -> Result<bool> {
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<Samples> {
let mut chunks: Vec<Samples> = Vec::new();
// The run of values the last merged row continued, if any.
let mut run: Option<FieldRun<'_>> = 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<Samples>) -> 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<Samples>, 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 {
+417 -5
View File
@@ -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<SeriesSchema> {
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>| {
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<Value>; 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<f64>, Option<&'a str>, Option<f64>);
/// 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<SeriesSchema>,
labels: &Labels,
rows: &[FragmentRow<'_>],
) -> (Series, Arc<HistogramBatch>) {
let mut batch = HistogramBatchBuilder::new();
let mut spans: Vec<GridSpan> = 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::<Vec<_>>(),
);
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.
//
@@ -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::<f64>()),
)
.saturating_add(layout)
}
/// The spans of each side.
fn spans(&self) -> [Vec<(i32, i32)>; 2] {
self.layout
+6 -7
View File
@@ -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();
+8 -7
View File
@@ -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]
+5 -2
View File
@@ -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,
+52
View File
@@ -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<SeriesSchema>,
function: ScalarFunction,
left: &Series,
right: &Series,
) -> Result<Option<Series>> {
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<SeriesSchema>,
+65
View File
@@ -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
+56
View File
@@ -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<f64> = 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<f64>]| {
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"
);
}
+31
View File
@@ -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<Series> {
eval_time_series(schema, |ms| ms as f64 / 1000.0)
@@ -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
+36 -1
View File
@@ -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);
}
+6 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,5 +29,6 @@
pub mod batch;
mod display;
pub mod execute;
pub mod metrics;
pub mod plan;
+23 -15
View File
@@ -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<EvalGrid>, 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
+40 -8
View File
@@ -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<EvalGrid>) -> 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()
+131 -12
View File
@@ -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<usize>],
/// 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<i64>,
/// 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<Vec<Range<usize>>>,
/// One output column per field, written by the batch kernel.
columns: Vec<Vec<Option<f64>>>,
@@ -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::<usize>()
+ self.eval_ts.capacity() * std::mem::size_of::<i64>()
+ spans * std::mem::size_of::<Range<usize>>()
+ columns * std::mem::size_of::<Option<f64>>()
}
/// 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::<usize>();
std::mem::size_of::<SeriesState>() + 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::<i64>()
+ self.values.capacity() * std::mem::size_of::<f64>()
+ self.histograms.capacity() * std::mem::size_of::<(i64, FloatHistogram)>()
+ self.resets.capacity() * std::mem::size_of::<CounterReset>()
+ 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<GridSample>`: 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<GridSample>]) -> usize {
row.iter()
.flatten()
.map(|sample| match sample {
GridSample::Histogram(histogram) => {
std::mem::size_of::<FloatHistogram>() + 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::<Option<GridSample>>();
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<SeriesSchema> {
&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(())
}
+5
View File
@@ -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)
}
+361 -16
View File
@@ -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<usize>) {
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<usize> = 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<usize> =
(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<Row> = (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///";
+30 -12
View File
@@ -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<SendableRawSeriesStream, BoxedError> {
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(),
));
+68 -59
View File
@@ -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<SendableRawSeriesStream>,
) -> Result<SendableRawSeriesStream, BoxedError> {
@@ -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<LabelDecodeSpec> {
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<LabelDecodeSpec, BoxedError> {
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<SendableRawSeriesStream>,
head: Option<RawSeriesChunk>,
@@ -223,7 +220,8 @@ impl Input {
struct OrderedRawMerge {
streams: Vec<Input>,
schema: Arc<SeriesSchema>,
binding: RawLabelSource,
/// The description the merged stream advertises for all inputs; see [`merged_decode_spec`].
decode: LabelDecodeSpec,
candidate: Option<RawSeriesChunk>,
}
@@ -441,8 +439,19 @@ impl RawSeriesStream for OrderedRawMerge {
fn schema(&self) -> &Arc<SeriesSchema> {
&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
+296 -160
View File
@@ -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<SeriesSchema>,
labels: RawLabelSource,
/// The description this input declares; `None` means its chunks carry complete labels.
decode: Option<LabelDecodeSpec>,
ordering: SeriesOrdering,
items: VecDeque<Poll<Option<Result<RawSeriesChunk, BoxedError>>>>,
// 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::<Vec<_>>()
.await
.unwrap();
assert_eq!(
rows.iter()
.map(|row| row.time.timestamp(0).unwrap())
.collect::<Vec<_>>(),
[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");
}
+31 -4
View File
@@ -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<SeriesSchema>,
labels: RawLabelSource,
/// The description this fixture declares, derived once from `labels`, as a producer does.
decode: Option<LabelDecodeSpec>,
items: VecDeque<Result<RawSeriesChunk, BoxedError>>,
}
@@ -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<RawSeriesChunk>) -> 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::<Vec<_>>()
@@ -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();
+83
View File
@@ -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<MetricName>, final_chunk: bool) -> Raw
struct MockStream {
schema: Arc<SeriesSchema>,
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<LabelDecodeSpec>,
ordering: SeriesOrdering,
items: VecDeque<Poll<Option<Result<RawSeriesChunk, BoxedError>>>>,
polls: Arc<AtomicUsize>,
@@ -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<AtomicUsize>, Arc<AtomicUsize>) {
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::<Vec<_>>(),
schema()
.fields
.iter()
.map(|field| field.name.to_string())
.collect::<Vec<_>>(),
"{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());
}
}
+93
View File
@@ -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![
+406 -98
View File
@@ -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
//! <selector> e.g. x
//! <range function>(<selector>[<range>]) e.g. rate(x[5m])
//! <range function>(<number>, <selector>[<range>]) e.g. quantile_over_time(0.9, x[5m])
//! <selector> e.g. x
//! <range function>(<selector>[<range>]) e.g. rate(x[5m])
//! <range function>(<constant>, <selector>[<range>]) e.g. quantile_over_time(0.9, x[5m])
//! <math function>(<expression>[, <constant>]*) 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
//! `<expression>` → `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 — \
`<range function>(<selector>[<range>])` — or a parameterised one, \
`<range function>(<number>, <selector>[<range>])`, 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 — \
`<range function>(<selector>[<range>])`, 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<SeriesSchema>,
ordering: SeriesOrdering,
) -> Result<NativeTree> {
// 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<SeriesSchema>,
/// 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<NativeNode> {
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<NativeNode> {
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<PromExpr>],
) -> Result<NativeNode> {
// 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, &parameters[0])?),
RangeSignature::Scalar(bind) => bind(constant_argument(name, &parameters[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<NativeNode> {
// 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<SeriesSchema>,
ordering: SeriesOrdering,
) -> Result<NativeTree> {
) -> Result<NativeNode> {
// 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<SeriesSchema>,
ordering: SeriesOrdering,
) -> Result<NativeTree> {
fn instant_selection(context: &Context, selector: &VectorSelector) -> Result<NativeNode> {
// 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<RangeSignature> {
}
}
/// 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<MathSignature> {
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<PromExpr>],
) -> Result<Option<(MathOp, &'a PromExpr)>> {
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, &parameters[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, &parameters[0], 1)?,
constant_argument(name, &parameters[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<f64> {
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<f64> {
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<f64> {
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<ArithmeticOp> {
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<RangeFunction> {
///
/// 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;
+385 -26
View File
@@ -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<Box<PromExpr>>))
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<PromExpr> {
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<SeriesSchema> {
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}"
);
}
}
-40
View File
@@ -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<Output>;
/// 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<Option<SeriesOrdering>> {
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<Option<PreparedNamedRawSeries>> {
Ok(None)
}
/// Execute a native single-region raw-series scan.
async fn execute_raw_series(
&self,
_plan: LogicalPlan,
_query_ctx: QueryContextRef,
_binding: RawSeriesRequest,
) -> Result<Output> {
crate::error::UnimplementedSnafu {
operation: "native raw-series query",
}
.fail()
}
/// Register an aggregate function.
///
/// # Panics
+1 -15
View File
@@ -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<SendableRecordBatchStream>;
/// 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<Option<PreparedNamedRawSeries>> {
Ok(None)
}
async fn handle_remote_dyn_filter_update(
&self,
target: &RegionQueryTarget,
+35 -3
View File
@@ -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<Item = Result<RawSeriesChunk, BoxedError>> + Send {
/// Returns the schema shared by all chunks; raw schemas have no evaluation grid.
fn schema(&self) -> &Arc<SeriesSchema>;
@@ -517,7 +530,26 @@ pub trait RawSeriesStream: Stream<Item = Result<RawSeriesChunk, BoxedError>> + 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;
}
@@ -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<RawSeriesChunk>,
schema: Arc<SeriesSchema>,
/// The description this fixture declares, derived once from `label_source`, as a producer does.
label_decode_spec: Option<LabelDecodeSpec>,
label_source: RawLabelSource,
polls: Arc<AtomicUsize>,
drops: Arc<AtomicUsize>,
@@ -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<LabelDecodeSpec> {
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::<Vec<_>>(reader)
.await
+3 -18
View File
@@ -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(
&current,
raw_label_mode(&self.label_source),
current_ordering,
)?;
let current_frame =
raw_series_frame_schema_with_ordering(&current, &self.label_source, current_ordering)?;
if current_frame != self.arrow_schema
|| scanner.properties().num_partitions().max(1) != partitions
{
+13 -5
View File
@@ -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<SeriesSchema>,
source: RawLabelSource,
/// The description this fixture declares, derived once from `source`, as a producer does.
label_decode_spec: Option<LabelDecodeSpec>,
chunks: Vec<RawSeriesChunk>,
state: Arc<Mutex<State>>,
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()
@@ -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.
@@ -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
}
}
}
]
}
@@ -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.
@@ -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"}}
@@ -1 +0,0 @@
{"version":1,"supported":null}
@@ -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": "<name>", "text": "<serde_json>"}`, 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)'
```
@@ -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
}
]
@@ -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}}"
}
]
@@ -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.
@@ -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]}