mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 14:56:54 +00:00
Merge remote-tracking branch 'upstream/narthex' into feat/narthex-native-compile-expr
# Conflicts: # src/query/src/promql/native_compile.rs
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)
|
||||
|
||||
Generated
+1
@@ -11377,6 +11377,7 @@ dependencies = [
|
||||
"snafu 0.8.6",
|
||||
"store-api",
|
||||
"tokio",
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -278,6 +278,7 @@ tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-opentelemetry = "0.33.0"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] }
|
||||
twox-hash = { version = "2.1", default-features = false, features = ["xxhash64"] }
|
||||
typetag = "0.2"
|
||||
uddsketch-rs = "0.1.0"
|
||||
uuid = { version = "1.17", features = ["serde", "v4", "v7", "fast-rng"] }
|
||||
|
||||
@@ -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,13 +14,8 @@
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+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`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+262
-169
@@ -31,13 +31,15 @@ use common_recordbatch::{RecordBatchStreamWrapper, SendableRecordBatchStream};
|
||||
use common_telemetry::tracing::{self, Instrument};
|
||||
use common_telemetry::{debug, warn};
|
||||
use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
|
||||
use datafusion::physical_plan::{DisplayAs, DisplayFormatType};
|
||||
use datafusion::physical_plan::{DisplayAs, DisplayFormatType, PhysicalExpr};
|
||||
use datafusion_common::tree_node::TreeNode;
|
||||
use datafusion_expr::{Expr, Volatility};
|
||||
use datatypes::arrow::array::BinaryArray;
|
||||
use datatypes::arrow::datatypes::DataType as ArrowDataType;
|
||||
use datatypes::arrow::record_batch::RecordBatch;
|
||||
use datatypes::prelude::ConcreteDataType;
|
||||
use datatypes::schema::SchemaRef;
|
||||
use datatypes::series::SeriesOrdering;
|
||||
use datatypes::series::samples::{FieldSpec, SeriesSchema};
|
||||
use datatypes::types::TimestampType;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
@@ -79,6 +81,49 @@ const SEND_TIMEOUT: Duration = Duration::from_micros(100);
|
||||
/// Maximum number of candidate series retained before distributing assignments.
|
||||
const CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD: usize = 1_000_000;
|
||||
|
||||
/// Test-only override of [`CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD`], where `0` means "use the
|
||||
/// production constant".
|
||||
///
|
||||
/// The production build compiles this static, the setter below and the private accessor out, so
|
||||
/// the shipped threshold is always the constant above.
|
||||
#[cfg(test)]
|
||||
static CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD_OVERRIDE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Test-only number of candidate assignment rounds sent since the override was last set.
|
||||
///
|
||||
/// A round is one `send_assignments` call, so a partition that received more than one assignment
|
||||
/// sees `>= 2` rounds here. Increments are compiled out of production builds.
|
||||
#[cfg(test)]
|
||||
static CANDIDATE_ASSIGNMENT_ROUNDS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Returns the candidate-series assignment threshold, honouring the test-only override.
|
||||
fn candidate_series_assignment_threshold() -> usize {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let threshold = CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD_OVERRIDE.load(Ordering::Relaxed);
|
||||
if threshold > 0 {
|
||||
return threshold;
|
||||
}
|
||||
}
|
||||
CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD
|
||||
}
|
||||
|
||||
/// Test-only: overrides the candidate-series assignment threshold and resets the round counter.
|
||||
///
|
||||
/// Pass `0` to restore the production threshold. Tests must reset the override before they
|
||||
/// return; a scope guard in `engine::scan_test` does that even on panic.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_candidate_series_assignment_threshold(threshold: usize) {
|
||||
CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD_OVERRIDE.store(threshold, Ordering::Relaxed);
|
||||
CANDIDATE_ASSIGNMENT_ROUNDS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Test-only: returns the number of assignment rounds sent since the override was last set.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn candidate_assignment_rounds() -> usize {
|
||||
CANDIDATE_ASSIGNMENT_ROUNDS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Legacy data receivers for output partitions.
|
||||
type LegacyReceiverList = Vec<Option<Receiver<Result<SeriesBatch>>>>;
|
||||
|
||||
@@ -88,6 +133,12 @@ type CandidateReceiverList = Vec<Option<CandidateReceiver>>;
|
||||
/// Candidate assignment senders for output partitions.
|
||||
type CandidateSenderList = Vec<Option<mpsc::UnboundedSender<Result<SeriesReaderInput>>>>;
|
||||
|
||||
struct RawScanPreparation {
|
||||
schema: Arc<SeriesSchema>,
|
||||
fields: Vec<(usize, ConcreteDataType)>,
|
||||
predicates: Vec<Arc<dyn PhysicalExpr>>,
|
||||
}
|
||||
|
||||
struct CandidateReceiver {
|
||||
receiver: mpsc::UnboundedReceiver<Result<SeriesReaderInput>>,
|
||||
active_receivers: Arc<AtomicUsize>,
|
||||
@@ -194,6 +245,189 @@ impl SeriesScan {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_raw_scan(
|
||||
&self,
|
||||
label_source: &RawLabelSource,
|
||||
) -> Result<Option<RawScanPreparation>, BoxedError> {
|
||||
let input = &self.stream_ctx.input;
|
||||
let metadata = input.mapper.metadata();
|
||||
match label_source {
|
||||
RawLabelSource::PrimaryKey {
|
||||
physical_metadata,
|
||||
logical_tags,
|
||||
..
|
||||
} => {
|
||||
if !Arc::ptr_eq(metadata, physical_metadata) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected metadata snapshot: region_id={}, physical_region_id={}, schema_version={}, physical_schema_version={}",
|
||||
metadata.region_id,
|
||||
physical_metadata.region_id,
|
||||
metadata.schema_version,
|
||||
physical_metadata.schema_version,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: "raw label source metadata does not match scanner snapshot"
|
||||
.to_string(),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
let mut names = HashSet::new();
|
||||
for tag in logical_tags.iter() {
|
||||
let column = metadata.column_by_id(tag.column_id).filter(|column| {
|
||||
column.semantic_type == SemanticType::Tag
|
||||
&& metadata.primary_key.contains(&tag.column_id)
|
||||
});
|
||||
let valid = column.is_some_and(|column| {
|
||||
column.column_schema.name == tag.name.as_ref()
|
||||
&& tag.name.as_ref() != "__name__"
|
||||
&& !is_internal_column(tag.name.as_ref())
|
||||
&& !is_metric_engine_internal_column(tag.name.as_ref())
|
||||
&& !ReservedColumnId::is_reserved(tag.column_id)
|
||||
});
|
||||
let unique_id = valid && ids.insert(tag.column_id);
|
||||
let unique_name = unique_id && names.insert(tag.name.as_ref());
|
||||
if !valid || !unique_id || !unique_name {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected label binding: region_id={}, column_id={}, binding_valid={}, unique_id={}, unique_name={}",
|
||||
metadata.region_id, tag.column_id, valid, unique_id, unique_name,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: "invalid raw label binding".to_string(),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
RawLabelSource::Explicit => {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected explicit labels: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let selected = input.mapper.output_schema();
|
||||
let mut fields = Vec::new();
|
||||
let mut specs = Vec::new();
|
||||
for output in selected.column_schemas() {
|
||||
let Some(column) = metadata.column_metadatas.iter().find(|column| {
|
||||
column.column_schema.name == output.name
|
||||
&& column.semantic_type == SemanticType::Field
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some((index, (_, data_type))) = input
|
||||
.mapper
|
||||
.batch_schema()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, (id, _))| *id == column.column_id)
|
||||
else {
|
||||
debug!(
|
||||
"Raw series scan preflight missing mapper field: region_id={}, column_id={}",
|
||||
metadata.region_id, column.column_id,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: format!(
|
||||
"selected field {} is missing from mapper batch schema",
|
||||
output.name
|
||||
),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
};
|
||||
if !data_type.is_numeric() && !is_native_histogram_value_type(data_type) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected field type: region_id={}, column_id={}",
|
||||
metadata.region_id, column.column_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
fields.push((index, data_type.clone()));
|
||||
specs.push(FieldSpec {
|
||||
name: Arc::from(output.name.as_str()),
|
||||
});
|
||||
}
|
||||
let time_is_millis = matches!(metadata.time_index_type(), TimestampType::Millisecond(_));
|
||||
if fields.is_empty()
|
||||
|| input.append_mode
|
||||
|| input.series_row_selector.is_some()
|
||||
|| !time_is_millis
|
||||
{
|
||||
debug!(
|
||||
"Raw series scan preflight rejected scan options: region_id={}, selected_fields={}, append_mode={}, row_selector={}, time_is_millis={}",
|
||||
metadata.region_id,
|
||||
fields.len(),
|
||||
input.append_mode,
|
||||
input.series_row_selector.is_some(),
|
||||
time_is_millis,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let schema = Arc::new(SeriesSchema {
|
||||
fields: specs.into(),
|
||||
eval_grid: None,
|
||||
});
|
||||
let typed_schema = input.mapper.input_arrow_schema(false);
|
||||
let mut predicates = Vec::new();
|
||||
if let Some(predicate) = input.predicate_group().predicate_without_region() {
|
||||
if !predicate.dyn_filters().is_empty() {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected dynamic predicate filters: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
for expr in predicate.exprs() {
|
||||
// Fresh execution properties cannot preserve Stable or query-dependent semantics.
|
||||
if expr
|
||||
.exists(|node| {
|
||||
Ok(matches!(node, Expr::ScalarFunction(function)
|
||||
if function.func.signature().volatility != Volatility::Immutable)
|
||||
|| matches!(node, Expr::ScalarVariable(..)))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
{
|
||||
debug!(
|
||||
"Raw series scan preflight rejected predicate volatility or variable: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// Evaluate residuals with original types before raw conversion to Float64.
|
||||
let Ok(physical) = Predicate::to_physical_expr(expr, &typed_schema) else {
|
||||
debug!(
|
||||
"Raw series scan preflight failed predicate compilation: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(
|
||||
physical.data_type(&typed_schema),
|
||||
Ok(ArrowDataType::Boolean | ArrowDataType::Null)
|
||||
) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected predicate type: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
predicates.push(physical);
|
||||
}
|
||||
}
|
||||
Ok(Some(RawScanPreparation {
|
||||
schema,
|
||||
fields,
|
||||
predicates,
|
||||
}))
|
||||
}
|
||||
|
||||
fn supports_two_phase(input: &ScanInput) -> bool {
|
||||
if !is_sparse_metric_metadata(input.region_metadata()) {
|
||||
return false;
|
||||
@@ -578,6 +812,22 @@ impl RegionScanner for SeriesScan {
|
||||
.map_err(BoxedError::new)
|
||||
}
|
||||
|
||||
fn raw_series_ordering(&self) -> SeriesOrdering {
|
||||
match self.mode {
|
||||
SeriesScanMode::Legacy => SeriesOrdering::KeyThenTime,
|
||||
SeriesScanMode::TwoPhase => SeriesOrdering::Contiguous,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_series_schema(
|
||||
&self,
|
||||
label_source: &RawLabelSource,
|
||||
) -> Result<Option<Arc<SeriesSchema>>, BoxedError> {
|
||||
Ok(self
|
||||
.prepare_raw_scan(label_source)?
|
||||
.map(|preparation| preparation.schema))
|
||||
}
|
||||
|
||||
fn scan_series_partition(
|
||||
&self,
|
||||
ctx: &QueryScanContext,
|
||||
@@ -585,171 +835,9 @@ impl RegionScanner for SeriesScan {
|
||||
partition: usize,
|
||||
label_source: &RawLabelSource,
|
||||
) -> Result<Option<SendableRawSeriesStream>, BoxedError> {
|
||||
let input = &self.stream_ctx.input;
|
||||
let metadata = input.mapper.metadata();
|
||||
match label_source {
|
||||
RawLabelSource::PrimaryKey {
|
||||
physical_metadata,
|
||||
logical_tags,
|
||||
..
|
||||
} => {
|
||||
if !Arc::ptr_eq(metadata, physical_metadata) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected metadata snapshot: region_id={}, physical_region_id={}, schema_version={}, physical_schema_version={}",
|
||||
metadata.region_id,
|
||||
physical_metadata.region_id,
|
||||
metadata.schema_version,
|
||||
physical_metadata.schema_version,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: "raw label source metadata does not match scanner snapshot"
|
||||
.to_string(),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
let mut names = HashSet::new();
|
||||
for tag in logical_tags.iter() {
|
||||
let column = metadata.column_by_id(tag.column_id).filter(|column| {
|
||||
column.semantic_type == SemanticType::Tag
|
||||
&& metadata.primary_key.contains(&tag.column_id)
|
||||
});
|
||||
let valid = column.is_some_and(|column| {
|
||||
column.column_schema.name == tag.name.as_ref()
|
||||
&& tag.name.as_ref() != "__name__"
|
||||
&& !is_internal_column(tag.name.as_ref())
|
||||
&& !is_metric_engine_internal_column(tag.name.as_ref())
|
||||
&& !ReservedColumnId::is_reserved(tag.column_id)
|
||||
});
|
||||
let unique_id = valid && ids.insert(tag.column_id);
|
||||
let unique_name = unique_id && names.insert(tag.name.as_ref());
|
||||
if !valid || !unique_id || !unique_name {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected label binding: region_id={}, column_id={}, binding_valid={}, unique_id={}, unique_name={}",
|
||||
metadata.region_id, tag.column_id, valid, unique_id, unique_name,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: "invalid raw label binding".to_string(),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
RawLabelSource::Explicit => {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected explicit labels: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let selected = input.mapper.output_schema();
|
||||
let mut fields = Vec::new();
|
||||
let mut specs = Vec::new();
|
||||
for output in selected.column_schemas() {
|
||||
let Some(column) = metadata.column_metadatas.iter().find(|column| {
|
||||
column.column_schema.name == output.name
|
||||
&& column.semantic_type == SemanticType::Field
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some((index, (_, data_type))) = input
|
||||
.mapper
|
||||
.batch_schema()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, (id, _))| *id == column.column_id)
|
||||
else {
|
||||
debug!(
|
||||
"Raw series scan preflight missing mapper field: region_id={}, column_id={}",
|
||||
metadata.region_id, column.column_id,
|
||||
);
|
||||
return Err(BoxedError::new(
|
||||
InvalidRecordBatchSnafu {
|
||||
reason: format!(
|
||||
"selected field {} is missing from mapper batch schema",
|
||||
output.name
|
||||
),
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
};
|
||||
if !data_type.is_numeric() && !is_native_histogram_value_type(data_type) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected field type: region_id={}, column_id={}",
|
||||
metadata.region_id, column.column_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
fields.push((index, data_type.clone()));
|
||||
specs.push(FieldSpec {
|
||||
name: Arc::from(output.name.as_str()),
|
||||
});
|
||||
}
|
||||
let time_is_millis = matches!(metadata.time_index_type(), TimestampType::Millisecond(_));
|
||||
if fields.is_empty()
|
||||
|| input.append_mode
|
||||
|| input.series_row_selector.is_some()
|
||||
|| !time_is_millis
|
||||
{
|
||||
debug!(
|
||||
"Raw series scan preflight rejected scan options: region_id={}, selected_fields={}, append_mode={}, row_selector={}, time_is_millis={}",
|
||||
metadata.region_id,
|
||||
fields.len(),
|
||||
input.append_mode,
|
||||
input.series_row_selector.is_some(),
|
||||
time_is_millis,
|
||||
);
|
||||
let Some(preparation) = self.prepare_raw_scan(label_source)? else {
|
||||
return Ok(None);
|
||||
}
|
||||
let schema = Arc::new(SeriesSchema {
|
||||
fields: specs.into(),
|
||||
eval_grid: None,
|
||||
});
|
||||
let typed_schema = input.mapper.input_arrow_schema(false);
|
||||
let mut predicates = Vec::new();
|
||||
if let Some(predicate) = input.predicate_group().predicate_without_region() {
|
||||
for expr in predicate.exprs() {
|
||||
// Fresh execution properties cannot preserve Stable or query-dependent semantics.
|
||||
if expr
|
||||
.exists(|node| {
|
||||
Ok(matches!(node, Expr::ScalarFunction(function)
|
||||
if function.func.signature().volatility != Volatility::Immutable)
|
||||
|| matches!(node, Expr::ScalarVariable(..)))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
{
|
||||
debug!(
|
||||
"Raw series scan preflight rejected predicate volatility or variable: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// Evaluate residuals with original types before raw conversion to Float64.
|
||||
let Ok(physical) = Predicate::to_physical_expr(expr, &typed_schema) else {
|
||||
debug!(
|
||||
"Raw series scan preflight failed predicate compilation: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(
|
||||
physical.data_type(&typed_schema),
|
||||
Ok(ArrowDataType::Boolean | ArrowDataType::Null)
|
||||
) {
|
||||
debug!(
|
||||
"Raw series scan preflight rejected predicate type: region_id={}",
|
||||
metadata.region_id,
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
predicates.push(physical);
|
||||
}
|
||||
}
|
||||
};
|
||||
let metrics = new_partition_metrics(
|
||||
&self.stream_ctx,
|
||||
ctx.explain_verbose,
|
||||
@@ -760,13 +848,15 @@ impl RegionScanner for SeriesScan {
|
||||
let batches = self
|
||||
.scan_batch_in_partition(ctx, partition, metrics.clone(), metrics_set)
|
||||
.map_err(BoxedError::new)?;
|
||||
let ordering = self.raw_series_ordering();
|
||||
Ok(Some(raw::make_stream(
|
||||
batches,
|
||||
schema,
|
||||
preparation.schema,
|
||||
label_source.clone(),
|
||||
fields,
|
||||
preparation.fields,
|
||||
metrics,
|
||||
predicates,
|
||||
preparation.predicates,
|
||||
ordering,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1096,7 +1186,7 @@ impl SeriesCandidateDistributor {
|
||||
return Ok(());
|
||||
}
|
||||
collector.push(batch);
|
||||
if collector.len() >= CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD {
|
||||
if collector.len() >= candidate_series_assignment_threshold() {
|
||||
chunked = true;
|
||||
self.send_assignments(collector.finish(false), &partition_pruner);
|
||||
if !self.should_fetch_candidates() {
|
||||
@@ -1120,6 +1210,9 @@ impl SeriesCandidateDistributor {
|
||||
assignments: Vec<AssignedSeriesBatch>,
|
||||
partition_pruner: &Arc<PartitionPruner>,
|
||||
) {
|
||||
// One round of candidate collection; a partition that receives two assignments sees two.
|
||||
#[cfg(test)]
|
||||
CANDIDATE_ASSIGNMENT_ROUNDS.fetch_add(1, Ordering::Relaxed);
|
||||
for (partition, assigned_series) in assignments.into_iter().enumerate() {
|
||||
if assigned_series.series().is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -128,6 +128,7 @@ fn chunk(
|
||||
struct RawStream {
|
||||
schema: Arc<SeriesSchema>,
|
||||
label_source: RawLabelSource,
|
||||
ordering: SeriesOrdering,
|
||||
inner: BoxStream<'static, Result<RawSeriesChunk, BoxedError>>,
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ impl RawSeriesStream for RawStream {
|
||||
&self.label_source
|
||||
}
|
||||
fn ordering(&self) -> SeriesOrdering {
|
||||
SeriesOrdering::Contiguous
|
||||
self.ordering
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,15 +158,16 @@ pub(crate) fn make_stream(
|
||||
fields: Vec<(usize, ConcreteDataType)>,
|
||||
metrics: PartitionMetrics,
|
||||
predicates: Vec<Arc<dyn PhysicalExpr>>,
|
||||
ordering: SeriesOrdering,
|
||||
) -> SendableRawSeriesStream {
|
||||
let output_schema = schema.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let mut input = inner;
|
||||
let mut pending: Option<RawSeriesChunk> = None;
|
||||
while let Some(item) = input.next().await {
|
||||
let (records, is_legacy) = match item {
|
||||
Ok(ScanBatch::Series(SeriesBatch::Flat(flat))) => (flat.batches.into_vec(), true),
|
||||
Ok(ScanBatch::RecordBatch(batch)) => (vec![batch], false),
|
||||
let records = match item {
|
||||
Ok(ScanBatch::Series(SeriesBatch::Flat(flat))) => flat.batches.into_vec(),
|
||||
Ok(ScanBatch::RecordBatch(batch)) => vec![batch],
|
||||
Err(error) => { yield Err(BoxedError::new(error)); return; }
|
||||
};
|
||||
for record in records {
|
||||
@@ -238,7 +240,7 @@ pub(crate) fn make_stream(
|
||||
};
|
||||
metrics.inc_convert_batch_cost(begun.elapsed());
|
||||
if let Some(old) = pending.as_ref()
|
||||
&& is_legacy
|
||||
&& ordering == SeriesOrdering::KeyThenTime
|
||||
&& old.primary_key.as_slice() != current.primary_key.as_slice()
|
||||
&& current.primary_key.as_slice() <= old.primary_key.as_slice()
|
||||
{
|
||||
@@ -265,6 +267,7 @@ pub(crate) fn make_stream(
|
||||
Box::pin(RawStream {
|
||||
schema,
|
||||
label_source,
|
||||
ordering,
|
||||
inner: Box::pin(stream),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use datatypes::arrow::record_batch::RecordBatch;
|
||||
use datatypes::data_type::DataType as _;
|
||||
use datatypes::prelude::ConcreteDataType;
|
||||
use datatypes::schema::ColumnSchema;
|
||||
use datatypes::series::SeriesOrdering;
|
||||
use datatypes::series::raw::RawSeriesChunk;
|
||||
use datatypes::series::samples::{FieldSpec, SampleRef, SeriesSchema};
|
||||
use futures::{Stream, StreamExt, stream};
|
||||
@@ -239,6 +240,7 @@ fn convert_with_source(
|
||||
schema: Arc<SeriesSchema>,
|
||||
source: RawLabelSource,
|
||||
field_type: ConcreteDataType,
|
||||
ordering: SeriesOrdering,
|
||||
) -> SendableRawSeriesStream {
|
||||
make_stream(
|
||||
inner,
|
||||
@@ -247,6 +249,7 @@ fn convert_with_source(
|
||||
vec![(0, field_type)],
|
||||
metrics(),
|
||||
vec![],
|
||||
ordering,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,6 +270,17 @@ fn convert(inner: ScanBatchStream, schema: Arc<SeriesSchema>) -> SendableRawSeri
|
||||
schema,
|
||||
primary_key_source(metadata()),
|
||||
ConcreteDataType::int64_datatype(),
|
||||
SeriesOrdering::Contiguous,
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_ordered(inner: ScanBatchStream, schema: Arc<SeriesSchema>) -> SendableRawSeriesStream {
|
||||
convert_with_source(
|
||||
inner,
|
||||
schema,
|
||||
primary_key_source(metadata()),
|
||||
ConcreteDataType::int64_datatype(),
|
||||
SeriesOrdering::KeyThenTime,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,6 +384,59 @@ async fn chunks_preserve_keys_values_times_and_final_flags_for_both_input_shapes
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_stream_reports_and_enforces_declared_ordering() {
|
||||
let stream = convert(input(vec![]), schema());
|
||||
assert_eq!(stream.ordering(), SeriesOrdering::Contiguous);
|
||||
drop(stream);
|
||||
let stream = convert_ordered(input(vec![]), schema());
|
||||
assert_eq!(stream.ordering(), SeriesOrdering::KeyThenTime);
|
||||
drop(stream);
|
||||
|
||||
let mut output = convert_ordered(
|
||||
input(vec![
|
||||
Ok(ScanBatch::RecordBatch(dictionary_batch(
|
||||
&[b"z"],
|
||||
&[1],
|
||||
&[Some(1)],
|
||||
))),
|
||||
Ok(ScanBatch::RecordBatch(dictionary_batch(
|
||||
&[b"a"],
|
||||
&[1],
|
||||
&[Some(2)],
|
||||
))),
|
||||
]),
|
||||
schema(),
|
||||
);
|
||||
assert!(output.next().await.unwrap().is_err());
|
||||
assert!(output.next().await.is_none());
|
||||
|
||||
let mut output = convert(
|
||||
input(vec![
|
||||
Ok(ScanBatch::RecordBatch(dictionary_batch(
|
||||
&[b"z"],
|
||||
&[1],
|
||||
&[Some(1)],
|
||||
))),
|
||||
Ok(ScanBatch::RecordBatch(dictionary_batch(
|
||||
&[b"a"],
|
||||
&[1],
|
||||
&[Some(2)],
|
||||
))),
|
||||
]),
|
||||
schema(),
|
||||
);
|
||||
assert_eq!(
|
||||
output.next().await.unwrap().unwrap().primary_key.as_slice(),
|
||||
b"z"
|
||||
);
|
||||
assert_eq!(
|
||||
output.next().await.unwrap().unwrap().primary_key.as_slice(),
|
||||
b"a"
|
||||
);
|
||||
assert!(output.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_predicate_filters_before_numeric_samples_are_cast() {
|
||||
let threshold = 1_i64 << 53;
|
||||
@@ -395,6 +462,7 @@ async fn typed_predicate_filters_before_numeric_samples_are_cast() {
|
||||
vec![(0, ConcreteDataType::int64_datatype())],
|
||||
metrics(),
|
||||
vec![predicate],
|
||||
SeriesOrdering::Contiguous,
|
||||
);
|
||||
let chunk = output.next().await.unwrap().unwrap();
|
||||
assert!(chunk.is_last);
|
||||
@@ -424,6 +492,7 @@ async fn predicate_error_is_terminal() {
|
||||
vec![(0, ConcreteDataType::int64_datatype())],
|
||||
metrics(),
|
||||
vec![predicate],
|
||||
SeriesOrdering::Contiguous,
|
||||
);
|
||||
assert!(output.next().await.unwrap().is_err());
|
||||
assert!(output.next().await.is_none());
|
||||
@@ -515,7 +584,7 @@ async fn legacy_series_requires_increasing_keys_but_record_batches_do_not() {
|
||||
let mut batches = smallvec::SmallVec::new();
|
||||
batches.push(dictionary_batch(&[b"b"], &[1], &[Some(1)]));
|
||||
batches.push(dictionary_batch(&[b"a"], &[2], &[Some(2)]));
|
||||
let mut output = convert(
|
||||
let mut output = convert_ordered(
|
||||
input(vec![Ok(ScanBatch::Series(SeriesBatch::Flat(
|
||||
FlatSeriesBatch { batches },
|
||||
)))]),
|
||||
@@ -528,7 +597,7 @@ async fn legacy_series_requires_increasing_keys_but_record_batches_do_not() {
|
||||
batches.push(dictionary_batch(&[b"a"], &[1], &[Some(1)]));
|
||||
batches.push(dictionary_batch(&[b"b"], &[1], &[Some(2)]));
|
||||
batches.push(dictionary_batch(&[b"a"], &[2], &[Some(3)]));
|
||||
let mut output = convert(
|
||||
let mut output = convert_ordered(
|
||||
input(vec![Ok(ScanBatch::Series(SeriesBatch::Flat(
|
||||
FlatSeriesBatch { batches },
|
||||
)))]),
|
||||
@@ -545,7 +614,7 @@ async fn legacy_series_requires_increasing_keys_but_record_batches_do_not() {
|
||||
let mut batches = smallvec::SmallVec::new();
|
||||
batches.push(dictionary_batch(&[b"a"], &[1], &[Some(1)]));
|
||||
batches.push(dictionary_batch(&[b"b"], &[1], &[Some(2)]));
|
||||
let mut output = convert(
|
||||
let mut output = convert_ordered(
|
||||
input(vec![Ok(ScanBatch::Series(SeriesBatch::Flat(
|
||||
FlatSeriesBatch { batches },
|
||||
)))]),
|
||||
@@ -636,6 +705,7 @@ async fn native_histogram_adapter_preserves_complete_payload() {
|
||||
native_histogram_value_type().clone(),
|
||||
)),
|
||||
native_histogram_value_type().clone(),
|
||||
SeriesOrdering::Contiguous,
|
||||
);
|
||||
assert!(matches!(
|
||||
raw.label_source(),
|
||||
|
||||
@@ -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.
|
||||
@@ -28,6 +28,7 @@ prost.workspace = true
|
||||
regex.workspace = true
|
||||
snafu.workspace = true
|
||||
store-api.workspace = true
|
||||
twox-hash.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
api.workspace = true
|
||||
|
||||
@@ -88,6 +88,17 @@ pub(crate) enum GridSample {
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Option<GridSample>>() == 16);
|
||||
|
||||
/// `sample` as an owned value; `None` when missing.
|
||||
pub(crate) fn owned(sample: SampleRef<'_>) -> Result<Option<GridSample>> {
|
||||
Ok(match sample {
|
||||
SampleRef::Missing => None,
|
||||
SampleRef::Float(value) => Some(GridSample::Float(value)),
|
||||
SampleRef::Histogram(view) => Some(GridSample::Histogram(Box::new(
|
||||
FloatHistogram::try_from_view(&view)?,
|
||||
))),
|
||||
})
|
||||
}
|
||||
|
||||
impl GridSeriesBuilder {
|
||||
/// Creates a builder for series of `schema`, which must have an evaluation grid.
|
||||
pub fn try_new(schema: Arc<SeriesSchema>) -> Result<Self> {
|
||||
@@ -309,7 +320,8 @@ 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.
|
||||
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;
|
||||
@@ -335,8 +347,7 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
|
||||
// slot indexes `members`, so the merge below picks the member's cursor directly instead of
|
||||
// searching for the member per row.
|
||||
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()];
|
||||
let mut merged: Vec<Option<GridSample>> = Vec::with_capacity(schema.fields.len());
|
||||
for (labels, members) in groups {
|
||||
if let [member] = members[..] {
|
||||
output.extend(series[member].take());
|
||||
@@ -361,7 +372,12 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
|
||||
.map(|field| field.cursor())
|
||||
.collect();
|
||||
for (point, row) in member_series.time.positions() {
|
||||
if read_row(&mut cursors, row, &mut values)? {
|
||||
if cursors.iter_mut().any(|cursor| {
|
||||
!matches!(
|
||||
cursor.at(row).expect("the axis and the fields are aligned"),
|
||||
SampleRef::Missing
|
||||
)
|
||||
}) {
|
||||
rows.push((point, member_slot, row));
|
||||
}
|
||||
}
|
||||
@@ -389,11 +405,12 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
|
||||
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);
|
||||
merged.clear();
|
||||
merged.resize_with(schema.fields.len(), || 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() {
|
||||
for (slot, cursor) in merged.iter_mut().zip(&mut cursors[member_slot]) {
|
||||
let sample = cursor.at(row).expect("the axis and the fields are aligned");
|
||||
if !matches!(sample, SampleRef::Missing) {
|
||||
if slot.is_some() {
|
||||
let labels: Vec<_> = labels
|
||||
.iter()
|
||||
@@ -404,11 +421,11 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
|
||||
labels.join(", ")
|
||||
)));
|
||||
}
|
||||
*slot = *value;
|
||||
*slot = owned(sample)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.push(point, &merged)?;
|
||||
builder.push_samples(point, &merged)?;
|
||||
start = end;
|
||||
}
|
||||
let metric = series[members[0]].as_ref().unwrap().metric.clone();
|
||||
@@ -417,24 +434,6 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
|
||||
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(),
|
||||
));
|
||||
}
|
||||
};
|
||||
present |= value.is_some();
|
||||
}
|
||||
Ok(present)
|
||||
}
|
||||
|
||||
pub(crate) fn external(error: datatypes::error::Error) -> DataFusionError {
|
||||
DataFusionError::External(Box::new(error))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::mem::size_of;
|
||||
use std::sync::Arc;
|
||||
|
||||
use datatypes::series::samples::{DenseSamplesRef, FieldSpec, SampleRef, SeriesSchema};
|
||||
use datatypes::series::samples::{DenseSamplesRef, FieldSpec, SampleRef, Series, SeriesSchema};
|
||||
use datatypes::series::time::{EvalGrid, GridSpec};
|
||||
use datatypes::series::{Label, MetricName};
|
||||
use rand::rngs::StdRng;
|
||||
@@ -25,7 +25,9 @@ use super::reference::{
|
||||
Labels, Points, assert_pieces_eq, assert_result_eq, points_of, random_dense, run_ranges,
|
||||
series_of, stitch,
|
||||
};
|
||||
use super::{GridSeriesBuilder, merge_same_labelset};
|
||||
use super::{GridSample, GridSeriesBuilder, merge_same_labelset};
|
||||
use crate::grid_series::reference::OwnedHistogram;
|
||||
use crate::histogram_eval::float_histogram::FloatHistogram;
|
||||
|
||||
fn grid_schema(fields: usize, grid_len: u64) -> Arc<SeriesSchema> {
|
||||
Arc::new(SeriesSchema {
|
||||
@@ -206,6 +208,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]
|
||||
|
||||
@@ -23,7 +23,10 @@ mod go_math;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::native::plan::{
|
||||
NativeType,
|
||||
};
|
||||
use crate::range_eval::RangeFunction;
|
||||
use crate::vector_match::{BinaryOp, Cardinality, MatchingLabels};
|
||||
use crate::vector_match::{BinaryOp, Cardinality, FillValues, MatchingLabels};
|
||||
|
||||
/// The header line of every tree.
|
||||
const HEADER: &str = "NativeTree";
|
||||
@@ -121,7 +121,7 @@ fn describe(node: &NativeNode) -> String {
|
||||
}
|
||||
),
|
||||
NativeExpr::Binary { matching, .. } => format!(
|
||||
"{}{} {} {}",
|
||||
"{}{} {} {}{}",
|
||||
binary_op(matching.op),
|
||||
if matching.return_bool { " bool" } else { "" },
|
||||
match &matching.labels {
|
||||
@@ -132,11 +132,25 @@ fn describe(node: &NativeNode) -> String {
|
||||
Cardinality::OneToOne => "one-to-one".to_string(),
|
||||
Cardinality::ManyToOne(labels) => format!("many-to-one [{}]", label_list(labels)),
|
||||
Cardinality::OneToMany(labels) => format!("one-to-many [{}]", label_list(labels)),
|
||||
}
|
||||
},
|
||||
fill(matching.fill)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(values: FillValues) -> String {
|
||||
let side = |name: &str, value: Option<f64>| {
|
||||
value
|
||||
.map(|value| format!(" {name} {value}"))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
format!(
|
||||
"{}{}",
|
||||
side("fill_left", values.lhs),
|
||||
side("fill_right", values.rhs)
|
||||
)
|
||||
}
|
||||
|
||||
/// A window: what it runs, over which range, and where it lands.
|
||||
fn window(op: &BoundWindow) -> String {
|
||||
format!(
|
||||
|
||||
@@ -24,7 +24,7 @@ use super::super::plan::{
|
||||
};
|
||||
use crate::map::MathOp;
|
||||
use crate::range_eval::{RangeFunction, RangeWindow};
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, MatchingLabels};
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels};
|
||||
|
||||
fn grid(start: i64, step: i64, len: u64) -> Arc<EvalGrid> {
|
||||
Arc::new(EvalGrid::try_new(GridSpec::try_new(start, step, len).unwrap()).unwrap())
|
||||
@@ -324,11 +324,12 @@ fn an_aggregate_and_a_binary_name_their_binding() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
},
|
||||
},
|
||||
output: evaluated(out),
|
||||
};
|
||||
let tree = NativeTree::try_new(binary).unwrap();
|
||||
let tree = NativeTree::try_new(binary.clone()).unwrap();
|
||||
assert_eq!(
|
||||
tree.to_string(),
|
||||
shown(&[
|
||||
@@ -339,6 +340,21 @@ fn an_aggregate_and_a_binary_name_their_binding() {
|
||||
" input Input raw samples",
|
||||
])
|
||||
);
|
||||
|
||||
// Fill values change the result, so a set one is part of the binding shown.
|
||||
let mut filled = binary;
|
||||
let NativeExpr::Binary { matching, .. } = &mut filled.expr else {
|
||||
unreachable!()
|
||||
};
|
||||
matching.fill.rhs = Some(0.5);
|
||||
assert_eq!(
|
||||
NativeTree::try_new(filled)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.lines()
|
||||
.nth(1),
|
||||
Some(" root Binary + ignoring [] one-to-one fill_right 0.5")
|
||||
);
|
||||
}
|
||||
|
||||
/// A `without` grouping reads as `without [...]`, not as `by [...]`.
|
||||
|
||||
@@ -452,6 +452,7 @@ mod tests {
|
||||
return_bool: false,
|
||||
labels: crate::vector_match::MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: crate::vector_match::Cardinality::OneToOne,
|
||||
fill: crate::vector_match::FillValues::default(),
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ use super::{
|
||||
};
|
||||
use crate::map::MathOp;
|
||||
use crate::range_eval::{RangeFunction, RangeWindow};
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, MatchingLabels};
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels};
|
||||
|
||||
fn grid(start: i64, step: i64, len: u64) -> Arc<EvalGrid> {
|
||||
Arc::new(EvalGrid::try_new(GridSpec::try_new(start, step, len).unwrap()).unwrap())
|
||||
@@ -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 {
|
||||
@@ -218,6 +230,7 @@ fn add() -> BinaryMatching {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,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()
|
||||
|
||||
@@ -80,7 +80,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],
|
||||
@@ -90,8 +91,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(
|
||||
@@ -167,6 +169,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>>>,
|
||||
@@ -202,9 +208,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);
|
||||
@@ -236,8 +239,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,
|
||||
@@ -1043,8 +1044,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,
|
||||
@@ -1073,6 +1072,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.
|
||||
|
||||
@@ -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,31 +965,6 @@ 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.
|
||||
#[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();
|
||||
let mut evaluator = RangeEvaluator::try_new(
|
||||
&raw_schema(1),
|
||||
grid(0, 10_000, 20),
|
||||
RangeWindow {
|
||||
range_ms: 30_000,
|
||||
offset_ms: 0,
|
||||
anchor_ms: None,
|
||||
},
|
||||
RangeFunction::Rate,
|
||||
)
|
||||
.unwrap();
|
||||
evaluator.push(&chunk(&rows, 1, 0, true)).unwrap();
|
||||
assert!(
|
||||
evaluator.batch_call_count() > 0,
|
||||
"rate over floats must reach the batch kernel"
|
||||
);
|
||||
}
|
||||
|
||||
/// A batch spanning more grid points than `EVALUATION_BATCH` flushes in the middle of one
|
||||
/// evaluation, and each field's windows come from that field's own samples.
|
||||
///
|
||||
@@ -881,7 +1035,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 +1052,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///";
|
||||
|
||||
+162
-18
@@ -13,9 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
//! Operators that depend on the whole set of evaluated series at each grid point: `topk`,
|
||||
//! `bottomk`, `scalar`, `absent` and the instant-result sorts.
|
||||
//! `bottomk`, `limitk`, `limit_ratio`, `scalar`, `absent` and the instant-result sorts.
|
||||
//!
|
||||
//! Semantics follow Prometheus (`aggregationK`, `funcScalar`, `funcAbsent`,
|
||||
//! Semantics follow Prometheus (`aggregationK`, `HashRatioSampler`, `funcScalar`, `funcAbsent`,
|
||||
//! `createLabelsForAbsentFunction`, `funcSort*`). A scalar is a grid series without labels that
|
||||
//! has a value at every point, so `vector(s)` is `s` itself and has no kernel.
|
||||
|
||||
@@ -29,7 +29,7 @@ use datatypes::series::time::union_positions;
|
||||
use datatypes::series::{Label, MetricName};
|
||||
|
||||
use crate::go_sort;
|
||||
use crate::grid_series::{GridSeriesBuilder, external};
|
||||
use crate::grid_series::{GridSeriesBuilder, external, owned};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -73,22 +73,9 @@ pub fn topk(
|
||||
// Validates the series against the schema and its grid; the walk below visits the carried
|
||||
// positions, not the whole grid.
|
||||
grid_points(schema, series)?;
|
||||
// Prometheus returns nothing for k below 1, rejects NaN and values out of int64, then
|
||||
// truncates k and caps it at the number of input series.
|
||||
if k < 1.0 {
|
||||
let Some(k) = k_param(k, series.len())? else {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if k.is_nan() {
|
||||
return Err(DataFusionError::Execution("Parameter value is NaN".into()));
|
||||
}
|
||||
// Prometheus' `maxInt64`, the largest float64 below 2^63.
|
||||
if k >= 9_223_372_036_854_774_784.0 {
|
||||
return Err(DataFusionError::Execution(format!(
|
||||
"Scalar value {k} overflows int64"
|
||||
)));
|
||||
}
|
||||
let k = k.trunc().min(series.len() as f64) as usize;
|
||||
|
||||
};
|
||||
let mut order: Vec<usize> = (0..series.len()).collect();
|
||||
order.sort_by(|&a, &b| compare_labels(&series[a].metric, &series[b].metric));
|
||||
let mut groups: HashMap<GroupKey, Vec<usize>> = HashMap::new();
|
||||
@@ -149,6 +136,144 @@ pub fn topk(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `limitk(k, ...)` with a constant `k`.
|
||||
///
|
||||
/// At every point each group keeps the first `k` series in input order that hold a sample there,
|
||||
/// float or histogram, with their labels and values. Prometheus takes them in the order its
|
||||
/// storage returns series: label order once a query reads several queriers (the head and a
|
||||
/// block), creation order when the TSDB head alone answers it. A caller that wants Prometheus'
|
||||
/// result over persisted data passes the series in label order.
|
||||
pub fn limitk(
|
||||
k: f64,
|
||||
grouping: &Grouping,
|
||||
schema: &SeriesSchema,
|
||||
series: &[Series],
|
||||
) -> Result<Vec<Series>> {
|
||||
single_field(schema, "limitk")?;
|
||||
grid_points(schema, series)?;
|
||||
let Some(k) = k_param(k, series.len())? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut groups: HashMap<GroupKey, Vec<usize>> = HashMap::new();
|
||||
for (index, item) in series.iter().enumerate() {
|
||||
groups
|
||||
.entry(group_key(&item.metric, grouping))
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
let mut selected: Vec<Vec<(usize, usize)>> = vec![Vec::new(); series.len()];
|
||||
for members in groups.values() {
|
||||
let mut cursors: Vec<RowCursor<'_>> = members
|
||||
.iter()
|
||||
.map(|&member| series[member].fields.as_slice()[0].cursor())
|
||||
.collect();
|
||||
for point in union_positions(members.iter().map(|&member| &series[member].time)) {
|
||||
let mut taken = 0;
|
||||
for (slot, &member) in members.iter().enumerate() {
|
||||
if taken == k {
|
||||
break;
|
||||
}
|
||||
let Some(row) = series[member].time.row_of(point) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
cursors[slot]
|
||||
.at(row)
|
||||
.expect("the axis and the fields are aligned"),
|
||||
SampleRef::Missing
|
||||
) {
|
||||
selected[member].push((point, row));
|
||||
taken += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone()))?;
|
||||
let mut result = Vec::new();
|
||||
for (index, points) in selected.into_iter().enumerate() {
|
||||
if points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
builder.begin_series();
|
||||
// A series belongs to one group, whose points are visited in order.
|
||||
let mut cursor = series[index].fields.as_slice()[0].cursor();
|
||||
for (point, row) in points {
|
||||
let sample = cursor.at(row).expect("the axis and the fields are aligned");
|
||||
builder.push_samples(point, &[owned(sample)?])?;
|
||||
}
|
||||
result.extend(builder.finish(series[index].metric.clone())?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `limit_ratio(r, ...)` with a constant `r`.
|
||||
///
|
||||
/// Keeps whole series: Prometheus derives an offset in `[0, 1)` from the hash of a series' labels
|
||||
/// and keeps the series when the offset is below `r`, or, for a negative `r`, at least `1 + r`.
|
||||
/// Which series are kept does not depend on the grouping, so it is not a parameter. The hash is
|
||||
/// the one of Prometheus' default `stringlabels` build; the other label builds hash differently.
|
||||
pub fn limit_ratio(ratio: f64, schema: &SeriesSchema, series: &[Series]) -> Result<Vec<Series>> {
|
||||
grid_points(schema, series)?;
|
||||
if ratio.is_nan() {
|
||||
return Err(DataFusionError::Execution("Ratio value is NaN".into()));
|
||||
}
|
||||
let ratio = ratio.clamp(-1.0, 1.0);
|
||||
let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone()))?;
|
||||
let mut result = Vec::new();
|
||||
let mut row = Vec::with_capacity(schema.fields.len());
|
||||
for item in series {
|
||||
let offset = sample_offset(&item.metric);
|
||||
let kept = if ratio >= 0.0 {
|
||||
offset < ratio
|
||||
} else {
|
||||
offset >= 1.0 + ratio
|
||||
};
|
||||
if !kept {
|
||||
continue;
|
||||
}
|
||||
// Rebuilt rather than cloned: an input may carry rows without a value, a result may not.
|
||||
builder.begin_series();
|
||||
let mut cursors: Vec<RowCursor<'_>> =
|
||||
item.fields.iter().map(|field| field.cursor()).collect();
|
||||
for (point, index) in item.time.positions() {
|
||||
row.clear();
|
||||
for cursor in &mut cursors {
|
||||
row.push(owned(
|
||||
cursor
|
||||
.at(index)
|
||||
.expect("the axis and the fields are aligned"),
|
||||
)?);
|
||||
}
|
||||
builder.push_samples(point, &row)?;
|
||||
}
|
||||
result.extend(builder.finish(item.metric.clone())?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prometheus `HashRatioSampler.SampleOffset`: the xxHash64 of the labels as `stringlabels`
|
||||
/// encodes them, each name and value prefixed by its length, over `u64::MAX`.
|
||||
fn sample_offset(metric: &MetricName) -> f64 {
|
||||
let mut data = Vec::new();
|
||||
for (name, value) in labels_with_name(metric) {
|
||||
// Prometheus label sets hold no empty value.
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for text in [&name, &value] {
|
||||
let len = text.len();
|
||||
if len < 255 {
|
||||
data.push(len as u8);
|
||||
} else {
|
||||
data.extend([255, len as u8, (len >> 8) as u8, (len >> 16) as u8]);
|
||||
}
|
||||
data.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
}
|
||||
twox_hash::XxHash64::oneshot(0, &data) as f64 / u64::MAX as f64
|
||||
}
|
||||
|
||||
/// `scalar(v)`: the value of the only series present at each point, NaN otherwise.
|
||||
pub fn scalar(schema: &SeriesSchema, series: &[Series]) -> Result<Series> {
|
||||
single_field(schema, "scalar")?;
|
||||
@@ -361,6 +486,25 @@ fn rank(a: f64, b: f64, top: bool) -> Ordering {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k` of `topk`, `bottomk` and `limitk` as Prometheus reads it: nothing for `k` below 1, an
|
||||
/// error for NaN or a value out of int64, otherwise `k` truncated and capped at the number of
|
||||
/// input series.
|
||||
fn k_param(k: f64, series: usize) -> Result<Option<usize>> {
|
||||
if k < 1.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if k.is_nan() {
|
||||
return Err(DataFusionError::Execution("Parameter value is NaN".into()));
|
||||
}
|
||||
// Prometheus' `maxInt64`, the largest float64 below 2^63.
|
||||
if k >= 9_223_372_036_854_774_784.0 {
|
||||
return Err(DataFusionError::Execution(format!(
|
||||
"Scalar value {k} overflows int64"
|
||||
)));
|
||||
}
|
||||
Ok(Some(k.trunc().min(series as f64) as usize))
|
||||
}
|
||||
|
||||
/// Label pairs identifying a group; empty values are absent, as in Prometheus.
|
||||
type GroupKey = Vec<(Arc<str>, Arc<str>)>;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+164
-40
@@ -24,14 +24,16 @@ use datatypes::series::time::{EvalGrid, GridRange, GridSpec, TimeAxis};
|
||||
use datatypes::series::{Label, MetricName};
|
||||
|
||||
use super::{
|
||||
Grouping, LabelMatcher, MatchOp, SortOrder, absent, absent_labels, natural_less, scalar,
|
||||
sort_instant, topk,
|
||||
Grouping, LabelMatcher, MatchOp, SortOrder, absent, absent_labels, limit_ratio, limitk,
|
||||
natural_less, sample_offset, scalar, sort_instant, topk,
|
||||
};
|
||||
use crate::golden::fold_pieces;
|
||||
use crate::grid_series::reference::{
|
||||
Dense, Labels, Points, assert_pieces_eq, assert_result_eq, piece_ranges, run_ranges,
|
||||
sparse_series,
|
||||
Dense, Labels, OwnedHistogram, Points, assert_pieces_eq, assert_result_eq, piece_ranges,
|
||||
run_ranges, sparse_series,
|
||||
};
|
||||
use crate::grid_series::{GridSample, GridSeriesBuilder};
|
||||
use crate::histogram_eval::float_histogram::FloatHistogram;
|
||||
|
||||
fn schema(points: u64, fields: usize) -> SeriesSchema {
|
||||
SeriesSchema {
|
||||
@@ -117,42 +119,20 @@ fn names(list: &str) -> Vec<Arc<str>> {
|
||||
/// header of `prometheus_cases.txt`.
|
||||
#[test]
|
||||
fn select_operators_match_prometheus() {
|
||||
let text = include_str!("prometheus_cases.txt");
|
||||
let mut checked = 0;
|
||||
for case in text.split("end\n").filter(|case| case.contains("case ")) {
|
||||
let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect();
|
||||
let spec: Vec<&str> = lines[0].split_whitespace().skip(1).collect();
|
||||
let points: usize = lines[1].split_whitespace().nth(3).unwrap().parse().unwrap();
|
||||
let schema = schema(points as u64, 1);
|
||||
let mut inputs = Vec::new();
|
||||
let mut expected: BTreeMap<String, Dense> = BTreeMap::new();
|
||||
for line in &lines[2..] {
|
||||
let (kind, rest) = line.split_once(' ').unwrap();
|
||||
let (labels, values) = rest.split_once(" ;").unwrap();
|
||||
match kind {
|
||||
"series" => inputs.push((metric(labels), dense(points, values))),
|
||||
"result" => {
|
||||
expected.insert(labels.to_string(), dense(points, values));
|
||||
}
|
||||
other => panic!("unexpected line {other} in {case}"),
|
||||
}
|
||||
}
|
||||
let series: Vec<Series> = inputs
|
||||
.iter()
|
||||
.filter_map(|(metric, dense)| build(&schema, metric.clone(), dense))
|
||||
.collect();
|
||||
|
||||
let actual: Vec<Series> = match spec[0] {
|
||||
let checked = check_cases(
|
||||
include_str!("prometheus_cases.txt"),
|
||||
|spec, schema, series| match spec[0] {
|
||||
"topk" => {
|
||||
let grouping = match spec[3] {
|
||||
"without" => Grouping::Without(names(spec[4])),
|
||||
"by" => Grouping::By(names(spec[4])),
|
||||
_ => Grouping::By(Vec::new()),
|
||||
};
|
||||
let k: f64 = spec[2].parse().unwrap();
|
||||
topk(k, spec[1] == "topk", &grouping, &schema, &series).unwrap()
|
||||
topk(
|
||||
k,
|
||||
spec[1] == "topk",
|
||||
&grouping(spec[3], spec[4]),
|
||||
schema,
|
||||
series,
|
||||
)
|
||||
}
|
||||
"scalar" => vec![scalar(&schema, &series).unwrap()],
|
||||
"scalar" => Ok(vec![scalar(schema, series).unwrap()]),
|
||||
"absent" => {
|
||||
let matchers: Vec<LabelMatcher> = spec
|
||||
.get(1)
|
||||
@@ -189,12 +169,156 @@ fn select_operators_match_prometheus() {
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
absent(&schema, &selected, absent_labels(&matchers).unwrap())
|
||||
Ok(absent(schema, &selected, absent_labels(&matchers).unwrap())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
other => panic!("unknown case {other}"),
|
||||
},
|
||||
);
|
||||
assert!(checked > 200, "{checked} result series checked");
|
||||
}
|
||||
|
||||
/// `limitk` and `limit_ratio` evaluated by the Prometheus v3.15.0 engine; see the header of
|
||||
/// `limit_cases.txt`.
|
||||
#[test]
|
||||
fn limit_operators_match_prometheus() {
|
||||
let checked = check_cases(include_str!("limit_cases.txt"), |spec, schema, series| {
|
||||
let param: f64 = spec[2].parse().unwrap();
|
||||
match spec[1] {
|
||||
"limitk" => limitk(param, &grouping(spec[3], spec[4]), schema, series),
|
||||
_ => limit_ratio(param, schema, series),
|
||||
}
|
||||
});
|
||||
assert!(checked > 200, "{checked} result series checked");
|
||||
}
|
||||
|
||||
/// The labels of the fixtures are short; these need the long length prefix of `stringlabels`.
|
||||
/// Prometheus' offsets were computed with promql-compat `cmd/narthex-golden-labelhash`.
|
||||
#[test]
|
||||
fn sample_offset_hashes_labels_like_prometheus() {
|
||||
let cases = [
|
||||
("__name__=a,job=x".to_string(), 0x3fe1c2f8b3f15aa8),
|
||||
(
|
||||
format!("__name__=a,long={}", "v".repeat(300)),
|
||||
0x3fca51ed01e04434,
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"__name__=a,{}={},zone={}",
|
||||
"n".repeat(254),
|
||||
"v".repeat(255),
|
||||
"z".repeat(70000)
|
||||
),
|
||||
0x3fc3bf72b8300ba3,
|
||||
),
|
||||
// A Prometheus label set has no empty value, so an empty label is no label.
|
||||
("__name__=a,empty=,job=x".to_string(), 0x3fe1c2f8b3f15aa8),
|
||||
];
|
||||
for (labels, offset) in cases {
|
||||
assert_eq!(sample_offset(&metric(&labels)).to_bits(), offset);
|
||||
}
|
||||
}
|
||||
|
||||
/// `limitk` and `limit_ratio` select series, so histogram samples pass through, and
|
||||
/// `limit_ratio` keeps every field of the series it keeps.
|
||||
#[test]
|
||||
fn limit_operators_keep_histogram_samples() {
|
||||
let histogram =
|
||||
OwnedHistogram::parse("0/0/0/3ff0000000000000/3ff0000000000000/0/1_1/3ff0000000000000///");
|
||||
let sample = || {
|
||||
GridSample::Histogram(Box::new(
|
||||
FloatHistogram::try_from_view(&histogram.view()).unwrap(),
|
||||
))
|
||||
};
|
||||
let kinds = |series: &Series, field: usize| -> Vec<(usize, &str)> {
|
||||
let samples = &series.fields.as_slice()[field];
|
||||
series
|
||||
.time
|
||||
.positions()
|
||||
.map(|(point, row)| {
|
||||
let kind = match samples.sample(row) {
|
||||
SampleRef::Missing => "missing",
|
||||
SampleRef::Float(_) => "float",
|
||||
SampleRef::Histogram(_) => "histogram",
|
||||
};
|
||||
(point, kind)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let two_fields = schema(3, 2);
|
||||
let mut builder = GridSeriesBuilder::try_new(Arc::new(two_fields.clone())).unwrap();
|
||||
builder
|
||||
.push_samples(0, &[Some(GridSample::Float(1.0)), Some(sample())])
|
||||
.unwrap();
|
||||
builder.push_samples(2, &[None, Some(sample())]).unwrap();
|
||||
let series = builder.finish(metric("__name__=a")).unwrap().unwrap();
|
||||
let kept = limit_ratio(1.0, &two_fields, std::slice::from_ref(&series)).unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kinds(&kept[0], 0), [(0, "float"), (2, "missing")]);
|
||||
assert_eq!(kinds(&kept[0], 1), [(0, "histogram"), (2, "histogram")]);
|
||||
|
||||
let one_field = schema(3, 1);
|
||||
let mut builder = GridSeriesBuilder::try_new(Arc::new(one_field.clone())).unwrap();
|
||||
builder.push_samples(1, &[Some(sample())]).unwrap();
|
||||
let series = builder.finish(metric("__name__=a")).unwrap().unwrap();
|
||||
let kept = limitk(1.0, &Grouping::By(Vec::new()), &one_field, &[series]).unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kinds(&kept[0], 0), [(1, "histogram")]);
|
||||
}
|
||||
|
||||
fn grouping(kind: &str, labels: &str) -> Grouping {
|
||||
match kind {
|
||||
"without" => Grouping::Without(names(labels)),
|
||||
"by" => Grouping::By(names(labels)),
|
||||
_ => Grouping::By(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates each case of a fixture with `eval` and returns how many result series matched; a
|
||||
/// case with an `error` line must fail with that message.
|
||||
fn check_cases(
|
||||
text: &str,
|
||||
eval: impl Fn(&[&str], &SeriesSchema, &[Series]) -> Result<Vec<Series>, DataFusionError>,
|
||||
) -> usize {
|
||||
let mut checked = 0;
|
||||
for case in text.split("end\n").filter(|case| case.contains("case ")) {
|
||||
let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect();
|
||||
let spec: Vec<&str> = lines[0].split_whitespace().skip(1).collect();
|
||||
let points: usize = lines[1].split_whitespace().nth(3).unwrap().parse().unwrap();
|
||||
let schema = schema(points as u64, 1);
|
||||
let mut inputs = Vec::new();
|
||||
let mut expected: BTreeMap<String, Dense> = BTreeMap::new();
|
||||
let mut error = None;
|
||||
for line in &lines[2..] {
|
||||
if let Some(message) = line.strip_prefix("error ") {
|
||||
error = Some(message);
|
||||
continue;
|
||||
}
|
||||
let (kind, rest) = line.split_once(' ').unwrap();
|
||||
let (labels, values) = rest.split_once(" ;").unwrap();
|
||||
match kind {
|
||||
"series" => inputs.push((metric(labels), dense(points, values))),
|
||||
"result" => {
|
||||
expected.insert(labels.to_string(), dense(points, values));
|
||||
}
|
||||
other => panic!("unexpected line {other} in {case}"),
|
||||
}
|
||||
}
|
||||
let series: Vec<Series> = inputs
|
||||
.iter()
|
||||
.filter_map(|(metric, dense)| build(&schema, metric.clone(), dense))
|
||||
.collect();
|
||||
|
||||
let actual = match (eval(&spec, &schema, &series), error) {
|
||||
(Ok(actual), None) => actual,
|
||||
(Err(actual), Some(expected)) => {
|
||||
assert_eq!(actual.message(), expected, "{case}");
|
||||
continue;
|
||||
}
|
||||
(actual, expected) => panic!("{case}\ngot {actual:?}, expected error {expected:?}"),
|
||||
};
|
||||
|
||||
// A result arrives as one series over the runs of valued positions; the fixtures pin the
|
||||
@@ -213,7 +337,7 @@ fn select_operators_match_prometheus() {
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 200, "{checked} result series checked");
|
||||
checked
|
||||
}
|
||||
|
||||
/// The fixture's label rendering: `name=value` pairs in name order, `-` when empty.
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 datatypes::arrow::array::Float64Array;
|
||||
use datatypes::arrow::buffer::{Buffer, ScalarBuffer};
|
||||
use datatypes::data_type::ConcreteDataType;
|
||||
@@ -431,6 +432,67 @@ async fn merges_full_encoded_keys_and_interleaved_chunks_with_global_finality()
|
||||
assert!(series[2].3.last().unwrap());
|
||||
}
|
||||
|
||||
/// Two inputs that each went through the frame codec merge like plain streams: unpacking keeps
|
||||
/// the declared ordering, and the merge restores global key order and per-key finality across
|
||||
/// the unpacked frames.
|
||||
#[tokio::test]
|
||||
async fn merges_two_unpacked_frames_through_the_frame_codec() {
|
||||
let rack_a_sensor_1 = read_key("rack-a", "sensor-1");
|
||||
let rack_b_sensor_1 = read_key("rack-b", "sensor-1");
|
||||
let framed = |labels: RawLabelSource, chunks: Vec<RawSeriesChunk>| {
|
||||
let (stream, _, _) = stream(
|
||||
labels,
|
||||
SeriesOrdering::KeyThenTime,
|
||||
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()
|
||||
};
|
||||
let left = framed(
|
||||
label_source(1),
|
||||
vec![
|
||||
chunk(rack_a_sensor_1.clone(), &[10, 30], &[1.0, 3.0], true),
|
||||
chunk(rack_b_sensor_1.clone(), &[5], &[5.0], true),
|
||||
],
|
||||
);
|
||||
let right = framed(
|
||||
label_source(2),
|
||||
vec![chunk(rack_a_sensor_1.clone(), &[20, 40], &[2.0, 4.0], true)],
|
||||
);
|
||||
|
||||
let mut merged = merge_ordered_raw_series(vec![left, right]).unwrap();
|
||||
assert_eq!(SeriesOrdering::KeyThenTime, merged.ordering());
|
||||
let mut series = Vec::<(Buffer, Vec<i64>, Vec<bool>)>::new();
|
||||
while let Some(item) = merged.next().await {
|
||||
let item = item.unwrap();
|
||||
if series
|
||||
.last()
|
||||
.is_none_or(|current| current.0 != item.primary_key)
|
||||
{
|
||||
series.push((item.primary_key.clone(), Vec::new(), Vec::new()));
|
||||
}
|
||||
let current = series.last_mut().unwrap();
|
||||
current.1.extend(item.time.as_buffer().to_vec());
|
||||
current.2.push(item.is_last);
|
||||
}
|
||||
assert_eq!(2, series.len());
|
||||
assert_eq!(rack_a_sensor_1, series[0].0);
|
||||
assert_eq!(vec![10, 20, 30, 40], series[0].1);
|
||||
assert!(*series[0].2.last().unwrap());
|
||||
assert!(
|
||||
series[0].2[..series[0].2.len() - 1]
|
||||
.iter()
|
||||
.all(|last| !last)
|
||||
);
|
||||
assert_eq!(rack_b_sensor_1, series[1].0);
|
||||
assert_eq!(vec![5], series[1].1);
|
||||
assert!(*series[1].2.last().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_equal_timestamp_from_separate_heads() {
|
||||
let key = read_key("rack-a", "sensor-1");
|
||||
|
||||
+187
-59
@@ -25,12 +25,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use datafusion::error::{DataFusionError, Result};
|
||||
use datatypes::series::MetricName;
|
||||
use datatypes::series::histogram::{CounterResetHint, HistogramView};
|
||||
use datatypes::series::samples::{RowCursor, SampleRef, Series, SeriesSchema};
|
||||
use datatypes::series::time::{EvalGrid, GridRunPair, TimeAxis};
|
||||
use datatypes::series::{Label, MetricName};
|
||||
|
||||
use crate::grid_series::{GridSample, GridSeriesBuilder, METADATA_LABELS};
|
||||
use crate::grid_series::{GridSample, GridSeriesBuilder, METADATA_LABELS, external, owned};
|
||||
use crate::histogram_eval::float_histogram::FloatHistogram;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -110,8 +110,30 @@ pub struct BinaryMatching {
|
||||
pub return_bool: bool,
|
||||
pub labels: MatchingLabels,
|
||||
pub cardinality: Cardinality,
|
||||
pub fill: FillValues,
|
||||
}
|
||||
|
||||
/// The values of `fill`, `fill_left` and `fill_right`, which stand in for a sample that has no
|
||||
/// match on the other side.
|
||||
///
|
||||
/// Prometheus applies them after it swaps the operands of `group_right`, so there `fill_left`
|
||||
/// stands in for a missing right-hand sample and `fill_right` for a missing left-hand one.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct FillValues {
|
||||
pub lhs: Option<f64>,
|
||||
pub rhs: Option<f64>,
|
||||
}
|
||||
|
||||
// Compared by bits: a NaN fill equals itself, and `fill(-0)` differs from `fill(0)` (`1 / fill`).
|
||||
impl PartialEq for FillValues {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let bits = |value: Option<f64>| value.map(f64::to_bits);
|
||||
bits(self.lhs) == bits(other.lhs) && bits(self.rhs) == bits(other.rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FillValues {}
|
||||
|
||||
/// A PromQL set operator between two instant vectors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SetOp {
|
||||
@@ -135,7 +157,8 @@ pub fn binary_output_schema(lhs: &SeriesSchema, rhs: &SeriesSchema) -> Result<Ar
|
||||
/// Fields are paired by position. Arithmetic and `bool` comparisons drop `__name__`, `__type__`
|
||||
/// and `__unit__`; comparisons without `bool` keep the left value where they hold. Native
|
||||
/// histograms support `+`, `-`, `==` and `!=` with each other, `*` with a float on either side
|
||||
/// and `/` by a float; Prometheus skips any other pairing of a histogram.
|
||||
/// and `/` by a float; Prometheus skips any other pairing of a histogram. A fill value pairs a
|
||||
/// sample that has no match with a float sample labelled with its match group.
|
||||
pub fn binary_vectors(
|
||||
matching: &BinaryMatching,
|
||||
lhs_schema: &SeriesSchema,
|
||||
@@ -174,18 +197,21 @@ fn binary_field(
|
||||
Cardinality::OneToMany(include) => (rhs, lhs, &include[..], true),
|
||||
};
|
||||
let points = grid.timestamps().len();
|
||||
// Prometheus short-circuits a point where either side is empty, before any check.
|
||||
let both_present: Vec<bool> = lhs
|
||||
let fill = matching.fill;
|
||||
// Prometheus short-circuits a point where either side is empty, before any check; with a fill
|
||||
// value only a point where both are empty.
|
||||
let filling = fill.lhs.is_some() || fill.rhs.is_some();
|
||||
let evaluated: Vec<bool> = lhs
|
||||
.presence(points)
|
||||
.into_iter()
|
||||
.zip(rhs.presence(points))
|
||||
.map(|(lhs, rhs)| lhs && rhs)
|
||||
.map(|(lhs, rhs)| if filling { lhs || rhs } else { lhs && rhs })
|
||||
.collect();
|
||||
|
||||
let one_groups = group(one, &matching.labels);
|
||||
let mut one_duplicate: Option<(usize, &GroupKey)> = None;
|
||||
for (key, members) in &one_groups {
|
||||
if let Some(point) = overlap(one, members, |point| both_present[point])
|
||||
if let Some(point) = overlap(one, members, |point| evaluated[point])
|
||||
&& one_duplicate.is_none_or(|(first, _)| point < first)
|
||||
{
|
||||
one_duplicate = Some((point, key));
|
||||
@@ -193,23 +219,23 @@ fn binary_field(
|
||||
}
|
||||
|
||||
let drop_name = !matching.op.is_comparison() || matching.return_bool;
|
||||
let mut results = Results::default();
|
||||
let group_keys: Vec<&GroupKey> = one_groups.keys().collect();
|
||||
let group_ids: HashMap<&GroupKey, usize> = group_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, key)| (*key, id))
|
||||
.collect();
|
||||
// One-to-one: a match group may be matched once per point; Prometheus counts a filtered
|
||||
// comparison as a match too, but not an operation that failed.
|
||||
let mut matched: HashSet<(usize, usize)> = HashSet::new();
|
||||
let mut implicit_many_to_one: Option<(usize, &GroupKey)> = None;
|
||||
let mut pairs = Pairs {
|
||||
matching,
|
||||
one_is_left,
|
||||
results: Results::default(),
|
||||
group_keys: Vec::new(),
|
||||
group_ids: HashMap::new(),
|
||||
matched: (matching.cardinality == Cardinality::OneToOne || fill.lhs.is_some())
|
||||
.then(HashSet::new),
|
||||
implicit_many_to_one: None,
|
||||
};
|
||||
for (many_index, many_series) in many.series.iter().enumerate() {
|
||||
let key = group_key(&many_series.metric, &matching.labels);
|
||||
let Some(candidates) = one_groups.get(&key) else {
|
||||
let candidates = one_groups.get(&key).map_or(&[][..], Vec::as_slice);
|
||||
if candidates.is_empty() && fill.rhs.is_none() {
|
||||
continue;
|
||||
};
|
||||
let group_id = group_ids[&key];
|
||||
}
|
||||
let group = pairs.group_id(&key);
|
||||
for &one_index in candidates {
|
||||
let metric = result_metric(
|
||||
&many_series.metric,
|
||||
@@ -237,36 +263,65 @@ fn binary_field(
|
||||
.at(pair.other_row + offset)
|
||||
.expect("a row never goes back"),
|
||||
);
|
||||
if matches!(many_value, SampleRef::Missing)
|
||||
|| matches!(one_value, SampleRef::Missing)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (lhs_value, rhs_value) = if one_is_left {
|
||||
(one_value, many_value)
|
||||
} else {
|
||||
(many_value, one_value)
|
||||
};
|
||||
let Some((result, keep)) = elem_binop(matching.op, lhs_value, rhs_value)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matching.cardinality == Cardinality::OneToOne
|
||||
&& !matched.insert((group_id, point))
|
||||
&& implicit_many_to_one.is_none_or(|(first, _)| point < first)
|
||||
{
|
||||
implicit_many_to_one = Some((point, group_keys[group_id]));
|
||||
}
|
||||
let value = if matching.return_bool {
|
||||
Some(GridSample::Float(if keep { 1.0 } else { 0.0 }))
|
||||
} else {
|
||||
keep.then_some(result)
|
||||
};
|
||||
results.insert(&metric, group_id, point, value);
|
||||
pairs.apply(group, &metric, point, many_value, one_value)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(value) = fill.rhs {
|
||||
let metric = result_metric(
|
||||
&many_series.metric,
|
||||
&metric_of(&key)?,
|
||||
matching,
|
||||
include,
|
||||
drop_name,
|
||||
)?;
|
||||
let mut cursor = many.cursor(many_index);
|
||||
let mut candidate_cursors: Vec<RowCursor<'_>> =
|
||||
candidates.iter().map(|&index| one.cursor(index)).collect();
|
||||
for (point, row) in many.positions(many_index) {
|
||||
let sample = cursor.at(row).expect("a row never goes back");
|
||||
let matched = candidates
|
||||
.iter()
|
||||
.zip(&mut candidate_cursors)
|
||||
.any(|(&index, cursor)| one.present(index, point, cursor));
|
||||
if !matched {
|
||||
pairs.apply(group, &metric, point, sample, SampleRef::Float(value))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prometheus fills the many side after every match of the step, for the samples of the one
|
||||
// side that no operation matched.
|
||||
if let Some(value) = fill.lhs {
|
||||
for (one_index, one_series) in one.series.iter().enumerate() {
|
||||
let key = group_key(&one_series.metric, &matching.labels);
|
||||
let group = pairs.group_id(&key);
|
||||
let metric = result_metric(
|
||||
&metric_of(&key)?,
|
||||
&one_series.metric,
|
||||
matching,
|
||||
include,
|
||||
drop_name,
|
||||
)?;
|
||||
let mut cursor = one.cursor(one_index);
|
||||
for (point, row) in one.positions(one_index) {
|
||||
if !pairs
|
||||
.matched
|
||||
.as_ref()
|
||||
.is_some_and(|matched| matched.contains(&(group, point)))
|
||||
{
|
||||
let sample = cursor.at(row).expect("a row never goes back");
|
||||
pairs.apply(group, &metric, point, SampleRef::Float(value), sample)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Pairs {
|
||||
mut results,
|
||||
group_keys,
|
||||
implicit_many_to_one,
|
||||
..
|
||||
} = pairs;
|
||||
// Prometheus fails at the first failing step. Within a step it checks the one side, then
|
||||
// matches, and only after the operator checks that result label sets are unique.
|
||||
if let Some((point, key)) = one_duplicate {
|
||||
@@ -283,7 +338,8 @@ fn binary_field(
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some((point, key)) = implicit_many_to_one {
|
||||
if let Some((point, group)) = implicit_many_to_one {
|
||||
let key = &group_keys[group];
|
||||
errors.push((
|
||||
point,
|
||||
1,
|
||||
@@ -319,6 +375,89 @@ fn binary_field(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The matched pairs of one field and the state Prometheus checks them against.
|
||||
struct Pairs<'a> {
|
||||
matching: &'a BinaryMatching,
|
||||
one_is_left: bool,
|
||||
results: Results,
|
||||
group_keys: Vec<GroupKey>,
|
||||
group_ids: HashMap<GroupKey, usize>,
|
||||
/// The match groups an operation matched at a point, kept where Prometheus consults them:
|
||||
/// one-to-one matching, and filling the many side. A filtered comparison counts as a match,
|
||||
/// an operation that failed does not.
|
||||
matched: Option<HashSet<(usize, usize)>>,
|
||||
implicit_many_to_one: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl Pairs<'_> {
|
||||
/// The id of the match group `key`, which stands for it in `matched` and in the results.
|
||||
fn group_id(&mut self, key: &GroupKey) -> usize {
|
||||
if let Some(&id) = self.group_ids.get(key) {
|
||||
return id;
|
||||
}
|
||||
let id = self.group_keys.len();
|
||||
self.group_keys.push(key.clone());
|
||||
self.group_ids.insert(key.clone(), id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Applies the operator to a sample of each side at `point`; a missing sample is no pair.
|
||||
fn apply(
|
||||
&mut self,
|
||||
group: usize,
|
||||
metric: &MetricName,
|
||||
point: usize,
|
||||
many_value: SampleRef<'_>,
|
||||
one_value: SampleRef<'_>,
|
||||
) -> Result<()> {
|
||||
if matches!(many_value, SampleRef::Missing) || matches!(one_value, SampleRef::Missing) {
|
||||
return Ok(());
|
||||
}
|
||||
let (lhs_value, rhs_value) = if self.one_is_left {
|
||||
(one_value, many_value)
|
||||
} else {
|
||||
(many_value, one_value)
|
||||
};
|
||||
let Some((result, keep)) = elem_binop(self.matching.op, lhs_value, rhs_value)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// One-to-one: a match group may be matched once per point.
|
||||
if let Some(matched) = &mut self.matched
|
||||
&& !matched.insert((group, point))
|
||||
&& self.matching.cardinality == Cardinality::OneToOne
|
||||
&& self
|
||||
.implicit_many_to_one
|
||||
.is_none_or(|(first, _)| point < first)
|
||||
{
|
||||
self.implicit_many_to_one = Some((point, group));
|
||||
}
|
||||
let value = if self.matching.return_bool {
|
||||
Some(GridSample::Float(if keep { 1.0 } else { 0.0 }))
|
||||
} else {
|
||||
keep.then_some(result)
|
||||
};
|
||||
self.results.insert(metric, group, point, value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The labels Prometheus gives a fill sample: the match group of the sample it pairs with.
|
||||
fn metric_of(key: &GroupKey) -> Result<MetricName> {
|
||||
let mut name = None;
|
||||
let mut labels = Vec::with_capacity(key.len());
|
||||
for (label, value) in key {
|
||||
if &**label == "__name__" {
|
||||
name = Some(value.clone());
|
||||
} else {
|
||||
labels.push(Label {
|
||||
name: label.clone(),
|
||||
value: value.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
MetricName::try_new(name, labels).map_err(external)
|
||||
}
|
||||
|
||||
fn earliest_error(errors: Vec<(usize, u8, String)>) -> Result<()> {
|
||||
match errors
|
||||
.into_iter()
|
||||
@@ -687,17 +826,6 @@ impl<'a> Operand<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `sample` as an owned value; `None` when missing.
|
||||
fn owned(sample: SampleRef<'_>) -> Result<Option<GridSample>> {
|
||||
Ok(match sample {
|
||||
SampleRef::Missing => None,
|
||||
SampleRef::Float(value) => Some(GridSample::Float(value)),
|
||||
SampleRef::Histogram(view) => Some(GridSample::Histogram(Box::new(
|
||||
FloatHistogram::try_from_view(&view)?,
|
||||
))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Prometheus `vectorElemBinop` over present samples: the value and whether to keep it, or
|
||||
/// `None` when Prometheus reports the pair as an annotation and skips it without counting the
|
||||
/// match: an operator the sample types do not support, or an addition or subtraction of
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,8 @@ use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use super::{
|
||||
BinaryMatching, BinaryOp, Cardinality, MatchingLabels, SetOp, binary_vectors, set_vectors,
|
||||
BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels, SetOp, binary_vectors,
|
||||
set_vectors,
|
||||
};
|
||||
use crate::grid_series::GridSeriesBuilder;
|
||||
use crate::grid_series::reference::{
|
||||
@@ -481,6 +482,7 @@ fn binary_vectors_match_prometheus_reference() {
|
||||
return_bool,
|
||||
labels,
|
||||
cardinality,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let fields = lhs_fields.min(rhs_fields);
|
||||
let points = grid.timestamps().len();
|
||||
@@ -555,6 +557,7 @@ fn rejects_operands_on_other_grids_and_unpaired_or_fields() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
assert!(binary_vectors(&matching, &lhs.schema, &lhs.series, &schema(1, &other), &[]).is_err());
|
||||
assert!(matches!(
|
||||
@@ -614,6 +617,7 @@ fn reports_the_earliest_failing_point() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::On(vec![Arc::from("job")]),
|
||||
cardinality: Cardinality::ManyToOne(Vec::new()),
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let error = binary_vectors(&matching, &schema, &lhs, &schema, &rhs)
|
||||
.unwrap_err()
|
||||
@@ -655,6 +659,26 @@ fn canonical_bits(value: f64) -> u64 {
|
||||
/// `prometheus_cases.txt`.
|
||||
#[test]
|
||||
fn vector_operations_match_prometheus() {
|
||||
let (matched, failed) = check_cases(include_str!("prometheus_cases.txt"));
|
||||
assert!(
|
||||
matched > 100 && failed > 20,
|
||||
"{matched} matched, {failed} failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Binary operations with fill modifiers, evaluated by the Prometheus v3.15.0 engine; see the
|
||||
/// header of `fill_cases.txt`.
|
||||
#[test]
|
||||
fn fill_modifiers_match_prometheus() {
|
||||
let (matched, failed) = check_cases(include_str!("fill_cases.txt"));
|
||||
assert!(
|
||||
matched > 100 && failed > 20,
|
||||
"{matched} matched, {failed} failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks each case of a fixture and returns how many matched results and how many failed.
|
||||
fn check_cases(text: &str) -> (usize, usize) {
|
||||
let parse_labels = |labels: &str| -> Labels {
|
||||
labels
|
||||
.split(',')
|
||||
@@ -672,7 +696,6 @@ fn vector_operations_match_prometheus() {
|
||||
.collect()
|
||||
};
|
||||
let (mut matched, mut failed) = (0, 0);
|
||||
let text = include_str!("prometheus_cases.txt");
|
||||
for case in text.split("end\n").filter(|case| case.contains("case ")) {
|
||||
let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect();
|
||||
let spec: Vec<&str> = lines[0].split_whitespace().skip(1).collect();
|
||||
@@ -729,6 +752,10 @@ fn vector_operations_match_prometheus() {
|
||||
other => panic!("unexpected line {other}"),
|
||||
}
|
||||
}
|
||||
let fill = |value: Option<&&str>| match value {
|
||||
None | Some(&"-") => None,
|
||||
Some(value) => Some(value.parse::<f64>().unwrap()),
|
||||
};
|
||||
let labels = |kind: &str, list: &str| match kind {
|
||||
"on" => MatchingLabels::On(names(list)),
|
||||
_ => MatchingLabels::Ignoring(names(list)),
|
||||
@@ -760,6 +787,10 @@ fn vector_operations_match_prometheus() {
|
||||
"right" => Cardinality::OneToMany(names(spec[6])),
|
||||
_ => Cardinality::OneToOne,
|
||||
},
|
||||
fill: FillValues {
|
||||
lhs: fill(spec.get(7)),
|
||||
rhs: fill(spec.get(8)),
|
||||
},
|
||||
};
|
||||
binary_vectors(&matching, &schema, &lhs, &schema, &rhs)
|
||||
}
|
||||
@@ -806,10 +837,7 @@ fn vector_operations_match_prometheus() {
|
||||
(actual, expected) => panic!("{case}\ngot {actual:?}, expected error {expected:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matched > 100 && failed > 20,
|
||||
"{matched} matched, {failed} failed"
|
||||
);
|
||||
(matched, failed)
|
||||
}
|
||||
|
||||
/// Results that share a label set once `__type__` is dropped fail as a grouping violation within
|
||||
@@ -858,6 +886,7 @@ fn colliding_results_fail_by_where_they_collide() {
|
||||
return_bool: false,
|
||||
labels,
|
||||
cardinality: Cardinality::ManyToOne(Vec::new()),
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let error = binary_vectors(&matching, &schema, &lhs, &schema, rhs)
|
||||
.unwrap_err()
|
||||
@@ -1002,6 +1031,7 @@ fn histogram_operators_match_prometheus() {
|
||||
"right" => Cardinality::OneToMany(Vec::new()),
|
||||
_ => Cardinality::OneToOne,
|
||||
},
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
binary_vectors(&matching, &schema, &lhs, &schema, &rhs)
|
||||
}
|
||||
@@ -1127,12 +1157,66 @@ fn colliding_reset_hints_skip_the_pair() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::On(vec![Arc::from("job")]),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let result = binary_vectors(&matching, &schema, &lhs, &schema, &rhs).unwrap();
|
||||
assert_eq!(result.len(), 1, "{op:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A pair whose operation fails is not a match, so `fill_left` still fills for the right-hand
|
||||
/// sample of that pair; the fixtures hold floats only.
|
||||
#[test]
|
||||
fn a_failed_pair_leaves_its_sample_to_the_fill() {
|
||||
let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 10_000, 1).unwrap()).unwrap());
|
||||
let schema = Arc::new(SeriesSchema {
|
||||
fields: vec![FieldSpec {
|
||||
name: Arc::from("value"),
|
||||
}]
|
||||
.into(),
|
||||
eval_grid: Some(grid),
|
||||
});
|
||||
let labels = |pairs: &[(&str, &str)]| -> Labels {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(name, value)| (name.to_string(), value.to_string()))
|
||||
.collect()
|
||||
};
|
||||
let histogram = "0/0/0/4014000000000000/4014000000000000/0/0_1/4014000000000000///";
|
||||
let lhs = [mixed_series(
|
||||
&schema,
|
||||
&labels(&[("__name__", "a"), ("job", "x")]),
|
||||
&BTreeMap::new(),
|
||||
&BTreeMap::from([(0, histogram.to_string())]),
|
||||
)
|
||||
.unwrap()];
|
||||
let rhs = [mixed_series(
|
||||
&schema,
|
||||
&labels(&[("__name__", "b"), ("job", "x")]),
|
||||
&BTreeMap::from([(0, 2.0f64.to_bits())]),
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap()];
|
||||
// A histogram plus a float is skipped, so `b` is unmatched: 10 + 2 under the match labels.
|
||||
let matching = BinaryMatching {
|
||||
op: BinaryOp::Add,
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::On(vec![Arc::from("job")]),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues {
|
||||
lhs: Some(10.0),
|
||||
rhs: None,
|
||||
},
|
||||
};
|
||||
let result = binary_vectors(&matching, &schema, &lhs, &schema, &rhs).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(labels_of(&result[0].metric), labels(&[("job", "x")]));
|
||||
assert!(matches!(
|
||||
result[0].fields.as_slice()[0].sample(0),
|
||||
SampleRef::Float(value) if value == 12.0
|
||||
));
|
||||
}
|
||||
|
||||
/// A binary pairing walks two operands of many single-position runs and still reports every
|
||||
/// position exactly once.
|
||||
///
|
||||
@@ -1170,6 +1254,7 @@ fn a_pairing_reads_many_single_position_runs_once() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::On(vec![Arc::from("job")]),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let result = binary_vectors(
|
||||
&matching,
|
||||
@@ -1218,6 +1303,7 @@ fn a_binary_pairing_of_sparse_operands_keeps_the_shared_positions() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let result = binary_vectors(
|
||||
&matching,
|
||||
@@ -1249,6 +1335,7 @@ fn a_binary_pairing_of_sparse_operands_keeps_the_shared_positions() {
|
||||
return_bool: false,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let result = binary_vectors(
|
||||
&filtered,
|
||||
@@ -1278,6 +1365,7 @@ fn a_binary_pairing_of_sparse_operands_keeps_the_shared_positions() {
|
||||
return_bool: true,
|
||||
labels: MatchingLabels::Ignoring(Vec::new()),
|
||||
cardinality: Cardinality::OneToOne,
|
||||
fill: FillValues::default(),
|
||||
};
|
||||
let result = binary_vectors(
|
||||
&booled,
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
//! 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;
|
||||
@@ -86,13 +88,12 @@ const SUPPORTED: &str = "a selector, a range function over a range 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 evaluation reads one series to
|
||||
/// its last chunk before the next, so chunks of different series may not interleave, and an
|
||||
/// `Interleaved` declaration is refused here for every shape this compiler accepts. That is where
|
||||
/// the refusal belongs: no accepted shape can consume interleaved chunks, so a tree declaring one
|
||||
/// cannot be built here 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
|
||||
|
||||
@@ -689,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),
|
||||
@@ -765,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]
|
||||
@@ -1119,28 +1159,3 @@ fn a_math_function_over_a_non_vector_argument_is_refused_and_names_it() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An interleaved scan is refused for every shape this compiler accepts, not only for the window
|
||||
/// that first needed the guarantee: a native evaluation reads one series to its last chunk before
|
||||
/// the next, so an instant over raw samples cannot consume interleaved chunks either. The refusal
|
||||
/// sits at the entry so that a shape added later inherits it, and a contiguous scan still compiles
|
||||
/// as the control that the refusal is about the ordering rather than about the queries.
|
||||
#[test]
|
||||
fn an_interleaved_scan_is_refused_whatever_the_shape() {
|
||||
for query in ["x", "abs(x)", "rate(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("interleave"),
|
||||
"`{query}` did not say why an interleaved scan cannot be read: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
compile(&eval_stmt("x"), raw_schema(), SeriesOrdering::Contiguous)
|
||||
.expect("`x` did not compile over a contiguous scan");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user