mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 14:56:54 +00:00
chore: merge feat/narthex-agg-stat into feat/narthex-agg-state
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 + '_ {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ®ion, &version, &bucket, &entry, &purger)
|
||||
let _handle = build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = store
|
||||
@@ -540,7 +646,7 @@ mod tests {
|
||||
};
|
||||
completed.push(entry.file_id);
|
||||
}
|
||||
build_series_index(&store, ®ion, &version, &bucket, &entry, &purger)
|
||||
build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
@@ -746,7 +852,7 @@ mod tests {
|
||||
.map(|_| ())
|
||||
} else {
|
||||
let (purger, _receiver) = series_index_channel(store.clone());
|
||||
build_series_index(&store, ®ion, &version, &bucket, &entry, &purger)
|
||||
build_series_index(&store, ®ion, &version, &bucket, &entry, None, &purger)
|
||||
.await
|
||||
.map(|_| ())
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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, ®ion, &version, file, &fake, &purger
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_with(&format!("{}/samples/", region.region_id.as_u64()))
|
||||
.recursive(true)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|entry| entry.metadata().is_dir())
|
||||
);
|
||||
assert!(region.series_index_version().samples.is_empty());
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeIndex;
|
||||
|
||||
#[async_trait]
|
||||
impl SeriesIndexFormat for FakeIndex {
|
||||
fn format(&self) -> ArtifactFormat {
|
||||
ArtifactFormat::FakeIndex
|
||||
}
|
||||
|
||||
async fn writer(
|
||||
&self,
|
||||
metadata: RegionMetadataRef,
|
||||
store: ObjectStore,
|
||||
entry: &SeriesIndexEntry,
|
||||
) -> Result<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,
|
||||
×tamps,
|
||||
MitoConfig {
|
||||
experimental_enable_series_index: true,
|
||||
experimental_series_scan_v2: true,
|
||||
experimental_series_index_maintenance_interval: Duration::from_secs(3600),
|
||||
min_compaction_interval: Duration::from_secs(3600),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
*region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats {
|
||||
index: Some(Arc::new(FakeIndex)),
|
||||
..Default::default()
|
||||
});
|
||||
let store = region.series_index_store.clone().unwrap();
|
||||
let (purger, mut purges) = series_index_channel(store.clone());
|
||||
let reconcile = || {
|
||||
reconcile_series_indexes(
|
||||
0,
|
||||
store.clone(),
|
||||
region.clone(),
|
||||
Duration::from_secs(100),
|
||||
0,
|
||||
purger.clone(),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
};
|
||||
assert_eq!(2, reconcile().await.unwrap().built_series);
|
||||
let before = region.series_index_version();
|
||||
assert!(
|
||||
before
|
||||
.series_indexes
|
||||
.values()
|
||||
.all(|h| h.entry().format() == ArtifactFormat::FakeIndex)
|
||||
);
|
||||
assert_eq!(0, reconcile().await.unwrap().built_series);
|
||||
let scanner = engine
|
||||
.scanner(
|
||||
region.region_id,
|
||||
store_api::storage::ScanRequest {
|
||||
distribution: Some(store_api::storage::TimeSeriesDistribution::PerSeries),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let batches = scanner
|
||||
.scan()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<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, ®ion.series_index_version()));
|
||||
assert_eq!(catalog, store.read(&catalog_path).await.unwrap().to_bytes());
|
||||
let cleanup = purges.try_recv().unwrap().objects.unwrap();
|
||||
assert_eq!(2, cleanup.len());
|
||||
assert!(
|
||||
cleanup
|
||||
.iter()
|
||||
.all(|path| !old.entry().artifact.objects.contains(path))
|
||||
);
|
||||
assert!(purges.try_recv().is_err());
|
||||
let reopened = load_version_control(&store, region.region_id, &purger).await;
|
||||
assert_eq!(
|
||||
old.entry(),
|
||||
reopened.current().samples[&old.entry().file_id].entry()
|
||||
);
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
@@ -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(¤t, &visible, &plan.expired_index_ids, &mut stats);
|
||||
@@ -218,6 +265,7 @@ async fn build_index_version(
|
||||
}
|
||||
if plan.builds.is_empty()
|
||||
&& next.is_none()
|
||||
&& formats.samples.is_none()
|
||||
&& (!enable_range_index || current.range_indexes.len() == visible.len())
|
||||
{
|
||||
return Ok((None, stats));
|
||||
@@ -225,13 +273,50 @@ async fn build_index_version(
|
||||
let SeriesIndexVersion {
|
||||
mut range_indexes,
|
||||
mut series_indexes,
|
||||
mut samples,
|
||||
..
|
||||
} = next.unwrap_or_else(|| SeriesIndexVersion {
|
||||
samples: current.samples.clone(),
|
||||
range_indexes: current.range_indexes.clone(),
|
||||
series_indexes: current.series_indexes.clone(),
|
||||
index_buckets: Default::default(),
|
||||
});
|
||||
let index_buckets = plan.index_buckets;
|
||||
if let Some(backend) = &formats.samples {
|
||||
for file in &files {
|
||||
let id = file.file_id().file_id();
|
||||
let reusable = if let Some(handle) = samples.get(&id) {
|
||||
let artifact = &handle.entry().artifact;
|
||||
let mut exists = artifact.format == backend.format();
|
||||
for path in &artifact.objects {
|
||||
exists &= store
|
||||
.exists(path)
|
||||
.await
|
||||
.context(crate::error::OpenDalSnafu)?;
|
||||
}
|
||||
exists
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if reusable {
|
||||
continue;
|
||||
}
|
||||
if let Some(handle) = crate::series_index::builder::build_samples(
|
||||
store,
|
||||
region,
|
||||
version,
|
||||
file.clone(),
|
||||
backend.as_ref(),
|
||||
purger,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
unpublished.1.push(handle.clone());
|
||||
samples.insert(id, handle);
|
||||
stats.built_samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &plan.superseded_index_ids {
|
||||
series_indexes.remove(id);
|
||||
}
|
||||
@@ -246,8 +331,16 @@ async fn build_index_version(
|
||||
range_indexes.insert(entry.file_id, entry);
|
||||
}
|
||||
}
|
||||
let series_handle =
|
||||
build_series_index(store, region, version, &bucket, &expected, purger).await?;
|
||||
let series_handle = build_series_index(
|
||||
store,
|
||||
region,
|
||||
version,
|
||||
&bucket,
|
||||
&expected,
|
||||
formats.index.as_deref(),
|
||||
purger,
|
||||
)
|
||||
.await?;
|
||||
unpublished.0.push(series_handle.clone());
|
||||
stats.built_series += 1;
|
||||
series_indexes.insert(expected.index_uuid, series_handle);
|
||||
@@ -276,6 +369,7 @@ async fn build_index_version(
|
||||
return Ok((None, stats));
|
||||
}
|
||||
let next = SeriesIndexVersion {
|
||||
samples,
|
||||
range_indexes,
|
||||
series_indexes,
|
||||
index_buckets,
|
||||
@@ -293,7 +387,8 @@ fn prune_index_version(
|
||||
expired_index_ids: &[FileId],
|
||||
stats: &mut ReconcileStats,
|
||||
) -> Option<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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(crate) async fn prepare_region_with_timestamps(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_region_with_config(
|
||||
pub(crate) async fn prepare_region_with_config(
|
||||
env: &mut TestEnv,
|
||||
timestamps: &[i64],
|
||||
config: MitoConfig,
|
||||
@@ -726,7 +726,7 @@ async fn test_failed_reconcile_retires_only_unpublished_series() {
|
||||
.unwrap();
|
||||
ranges.insert(entry.file_id, entry);
|
||||
}
|
||||
let handle = build_series_index(store, ®ion, &version, bucket, entry, purger)
|
||||
let handle = build_series_index(store, ®ion, &version, bucket, entry, None, purger)
|
||||
.await
|
||||
.unwrap();
|
||||
let reused_path = series_index_path(region.region_id, entry.index_uuid);
|
||||
@@ -891,6 +891,7 @@ async fn test_drop_catalogs_and_retained_snapshot_after_task_stop() {
|
||||
let store = ObjectStore::new(Memory::default()).unwrap();
|
||||
let region_id = RegionId::new(1, 1);
|
||||
let entry = SeriesIndexEntry {
|
||||
artifact: None,
|
||||
file_size: 0,
|
||||
index_uuid: FileId::random(),
|
||||
bucket_start: Timestamp::new_second(0),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -29,5 +29,6 @@
|
||||
|
||||
pub mod batch;
|
||||
mod display;
|
||||
pub mod execute;
|
||||
pub mod metrics;
|
||||
pub mod plan;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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, ¶meters[0])?),
|
||||
RangeSignature::Scalar(bind) => bind(constant_argument(name, ¶meters[0], 0)?),
|
||||
};
|
||||
let argument = &selector[0];
|
||||
let PromExpr::MatrixSelector(MatrixSelector { vs, range }) = unparen(argument) else {
|
||||
return Err(not_implemented(&format!(
|
||||
"a call to `{}` whose argument is `{}`",
|
||||
func.name, argument
|
||||
"a call to `{name}` whose argument is `{argument}`"
|
||||
)));
|
||||
};
|
||||
// A native range window reads one series to its last chunk before it reads the next, so raw
|
||||
// samples whose chunks of different series interleave cannot be folded by it. The declaration
|
||||
// is refused here rather than recorded onto an input the tree is built over and fails on.
|
||||
if ordering == SeriesOrdering::Interleaved {
|
||||
return Err(not_implemented(
|
||||
"raw samples whose series interleave, which this compiler cannot consume: its range \
|
||||
window reads one series to its last chunk before the next, so chunks of different \
|
||||
series may not interleave",
|
||||
));
|
||||
window_selection(context, function, vs, range)
|
||||
}
|
||||
|
||||
/// Wraps `op` over the value of `vector`, which is the first argument of a math function.
|
||||
///
|
||||
/// The output is the input's own type: a pointwise function moves no value to another grid point
|
||||
/// and reports one value per field it was given, so the map's schema is the input's allocation
|
||||
/// rather than a copy that merely agrees with it. [`NativeTree::validate`] does not require that
|
||||
/// sharing: it compares the map's grid with its input's by [`EvalGrid::spec`] and its field count
|
||||
/// with its input's, so a map that allocated an equal grid of its own would validate the same way.
|
||||
/// The only comparison by identity is an input node's, whose output has to be the very binding it
|
||||
/// was bound to.
|
||||
fn map_call(context: &Context, name: &str, op: MathOp, vector: &PromExpr) -> Result<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, ¶meters[0], 1)?),
|
||||
MathSignature::DefaultedConstant { default, bind } => bind(match parameters.first() {
|
||||
Some(parameter) => constant_argument(name, parameter, 1)?,
|
||||
None => default,
|
||||
}),
|
||||
MathSignature::TwoConstants(bind) => bind(
|
||||
constant_argument(name, ¶meters[0], 1)?,
|
||||
constant_argument(name, ¶meters[1], 2)?,
|
||||
),
|
||||
};
|
||||
Ok(Some((op, vector)))
|
||||
}
|
||||
|
||||
/// The rejection of a math call that wrote `given` arguments where `signature` takes another count.
|
||||
///
|
||||
/// The count is the signature's, so the message says what this function takes rather than what one
|
||||
/// argument happens to be. The vector is counted in: it is the function's first argument, and a
|
||||
/// reader counting the call's arguments should not have to add it themselves.
|
||||
fn arity_error(name: &str, signature: MathSignature, given: usize) -> DataFusionError {
|
||||
not_implemented(&format!(
|
||||
"a call to `{name}` with {given} argument(s), where it takes {}",
|
||||
signature.arguments()
|
||||
))
|
||||
}
|
||||
|
||||
/// The constant `argument` folds to, or a rejection naming the argument and where it was read.
|
||||
///
|
||||
/// A parameter is a constant of the statement: a quantile, a rounding step or a clamp bound is a
|
||||
/// field of the tree, so a parameter whose value is only known at run time — `time()` being the real
|
||||
/// case — has nowhere to go. It is refused rather than answered from the wrong number, and the
|
||||
/// rejection names the expression, because that is what the query has to change.
|
||||
///
|
||||
/// A quantile outside `[0, 1]` is not refused here: Prometheus binds it and the kernel reports
|
||||
/// `-Infinity` below zero and `+Infinity` above one, so refusing it would be this compiler
|
||||
/// inventing a rule the query engine does not have.
|
||||
fn scalar_parameter(name: &str, parameter: &PromExpr) -> Result<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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,7 +57,6 @@ use datafusion_physical_expr::{
|
||||
use datatypes::arrow::datatypes::SchemaRef as ArrowSchemaRef;
|
||||
use datatypes::compute::SortOptions;
|
||||
use datatypes::series::SeriesOrdering;
|
||||
use datatypes::series::frame::RawLabelMode;
|
||||
use datatypes::series::samples::SeriesSchema;
|
||||
use futures::{Stream, StreamExt};
|
||||
use store_api::metric_engine_consts::DATA_SCHEMA_TSID_COLUMN_NAME;
|
||||
@@ -593,11 +592,7 @@ impl RegionScanExec {
|
||||
return Ok(None);
|
||||
};
|
||||
let ordering = scanner.raw_series_ordering();
|
||||
let arrow_schema = raw_series_frame_schema_with_ordering(
|
||||
&schema,
|
||||
raw_label_mode(&label_source),
|
||||
ordering,
|
||||
)?;
|
||||
let arrow_schema = raw_series_frame_schema_with_ordering(&schema, &label_source, ordering)?;
|
||||
let partition_count = scanner.properties().num_partitions().max(1);
|
||||
let properties = Arc::new(PlanProperties::new(
|
||||
EquivalenceProperties::new(arrow_schema.clone()),
|
||||
@@ -667,13 +662,6 @@ impl SeriesScanExec {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_label_mode(source: &RawLabelSource) -> RawLabelMode {
|
||||
match source {
|
||||
RawLabelSource::PrimaryKey { .. } => RawLabelMode::Key,
|
||||
RawLabelSource::Explicit => RawLabelMode::Explicit,
|
||||
}
|
||||
}
|
||||
|
||||
fn same_label_source(left: &RawLabelSource, right: &RawLabelSource) -> bool {
|
||||
match (left, right) {
|
||||
(RawLabelSource::Explicit, RawLabelSource::Explicit) => true,
|
||||
@@ -785,11 +773,8 @@ impl ExecutionPlan for SeriesScanExec {
|
||||
DataFusionError::Execution("raw series capability changed after planning".into())
|
||||
})?;
|
||||
let current_ordering = scanner.raw_series_ordering();
|
||||
let current_frame = raw_series_frame_schema_with_ordering(
|
||||
¤t,
|
||||
raw_label_mode(&self.label_source),
|
||||
current_ordering,
|
||||
)?;
|
||||
let current_frame =
|
||||
raw_series_frame_schema_with_ordering(¤t, &self.label_source, current_ordering)?;
|
||||
if current_frame != self.arrow_schema
|
||||
|| scanner.properties().num_partitions().max(1) != partitions
|
||||
{
|
||||
|
||||
@@ -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]}
|
||||
Reference in New Issue
Block a user