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:
discord9
2026-10-21 17:26:42 +08:00
59 changed files with 7866 additions and 1081 deletions
+1
View File
@@ -62,6 +62,7 @@ a module map, read/write paths, change-coupling points, and gotchas:
- [`src/servers/AGENTS.md`](src/servers/AGENTS.md)
- [`src/operator/AGENTS.md`](src/operator/AGENTS.md)
- [`src/mito2/AGENTS.md`](src/mito2/AGENTS.md)
- [`src/promql/AGENTS.md`](src/promql/AGENTS.md)
- [`src/metric-engine/AGENTS.md`](src/metric-engine/AGENTS.md)
- [`src/flow/AGENTS.md`](src/flow/AGENTS.md)
- [`src/frontend/AGENTS.md`](src/frontend/AGENTS.md)
Generated
+1
View File
@@ -11377,6 +11377,7 @@ dependencies = [
"snafu 0.8.6",
"store-api",
"tokio",
"twox-hash",
]
[[package]]
+1
View File
@@ -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"] }
+2 -118
View File
@@ -18,21 +18,10 @@ use api::region::RegionResponse;
use api::v1::flow::{DirtyWindowRequests, FlowRequest, FlowResponse};
use api::v1::region::{InsertRequests, RegionRequest};
pub use common_base::AffectedRows;
use common_error::ext::{BoxedError, PlainError};
use common_error::status_code::StatusCode;
use common_query::error::Error as CommonQueryError;
use common_query::request::{
PreparedNamedRawSeries, QueryRequest, RAW_SERIES_REQUEST_EXTENSION_KEY, RawSeriesRequest,
};
use common_query::series_frame::{raw_series_frame_ordering, unpack_raw_series};
use common_query::request::QueryRequest;
use common_recordbatch::SendableRecordBatchStream;
use common_recordbatch::adapter::DfRecordBatchStreamAdapter;
use datatypes::series::SeriesOrdering;
use datatypes::series::samples::SeriesSchema;
use snafu::{ResultExt, location};
use store_api::region_engine::{RawLabelSource, SendableRawSeriesStream};
use crate::error::{ExternalSnafu, Result};
use crate::error::Result;
use crate::peer::Peer;
/// The trait for handling requests to datanode.
@@ -43,111 +32,6 @@ pub trait Datanode: Send + Sync {
/// Handles query requests
async fn handle_query(&self, request: QueryRequest) -> Result<SendableRecordBatchStream>;
/// Prepares a bound raw-series query without executing it.
///
/// Implementations that do not support preflight return `Ok(None)` without execution or a
/// validation guarantee; failures other than unsupported preparation are returned as errors.
async fn prepare_raw_series_query(
&self,
_request: QueryRequest,
_binding: RawSeriesRequest,
) -> Result<Option<SeriesOrdering>> {
Ok(None)
}
/// Non-consumingly checks whether a named raw-series preflight is supported.
///
/// Returns the approved binding and ordering on support, or `None` when the request is
/// explicitly unsupported; failures that are not unsupported preparation are returned as
/// errors. This point-in-time check does not reserve a plan, and execution revalidates
/// support.
async fn prepare_named_raw_series_query(
&self,
_request: QueryRequest,
) -> Result<Option<PreparedNamedRawSeries>> {
Ok(None)
}
/// Handles a bound raw-series query and decodes its series frames.
///
/// The binding is an expectation supplied by the already-authorized internal planner; it is
/// not evidence of provenance or an independent authorization grant.
async fn handle_raw_series_query(
&self,
request: QueryRequest,
binding: RawSeriesRequest,
) -> Result<SendableRawSeriesStream> {
let (request, expected_schema, label_source) = bind_raw_series_query(request, &binding)?;
let stream = self.handle_query(request).await?;
let ordering = raw_series_frame_ordering(stream.schema().arrow_schema().as_ref());
let stream = Box::pin(DfRecordBatchStreamAdapter::new(stream));
ordering
.and_then(|ordering| unpack_raw_series(stream, expected_schema, label_source, ordering))
.map_err(|error| {
BoxedError::new(CommonQueryError::GeneralDataFusion {
error,
location: location!(),
})
})
.context(ExternalSnafu)
}
}
/// Binds a raw-series request to the query context without executing the query.
pub fn bind_raw_series_query(
mut request: QueryRequest,
binding: &RawSeriesRequest,
) -> Result<(QueryRequest, Arc<SeriesSchema>, RawLabelSource)> {
if request.region_id != binding.physical_metadata.region_id {
return Err(BoxedError::new(PlainError::new(
"raw-series request region does not match binding".to_string(),
StatusCode::InvalidArguments,
)))
.context(ExternalSnafu);
}
let (expected_schema, label_source) = binding
.bind(Arc::new(binding.physical_metadata.clone()))
.map_err(|error| {
BoxedError::new(CommonQueryError::GeneralDataFusion {
error,
location: location!(),
})
})
.context(ExternalSnafu)?;
if request
.header
.as_ref()
.and_then(|header| header.query_context.as_ref())
.is_some_and(|context| {
context
.extensions
.contains_key(RAW_SERIES_REQUEST_EXTENSION_KEY)
})
{
return Err(BoxedError::new(PlainError::new(
"raw-series request marker already exists".to_string(),
StatusCode::InvalidArguments,
)))
.context(ExternalSnafu);
}
let extension = binding
.to_extension_value()
.map_err(|error| {
BoxedError::new(CommonQueryError::GeneralDataFusion {
error,
location: location!(),
})
})
.context(ExternalSnafu)?;
request
.header
.get_or_insert_default()
.query_context
.get_or_insert_default()
.extensions
.insert(RAW_SERIES_REQUEST_EXTENSION_KEY.to_string(), extension);
Ok((request, expected_schema, label_source))
}
impl dyn Datanode + '_ {
-5
View File
@@ -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
View File
@@ -26,7 +26,7 @@ snapshot isolation). It implements the `RegionEngine` trait from `store-api`.
| `compaction` | `src/mito2/src/compaction/` | Compaction scheduler (`scheduler.rs` + `scheduler/`), TWCS picker, strict-window manual picker, compactor, memory control |
| `access_layer` | `src/mito2/src/access_layer.rs` | SST read/write over the object store |
| `sst` | `src/mito2/src/sst/` | Parquet format, file metadata, index layout |
| `series_index` | `src/mito2/src/series_index/` | Series index writer/searcher, bucket planning, catalogs, worker-owned reconciliation (`maintenance.rs`), shared approximate usage (`task.rs`), catalogs, and index lifecycle |
| `series_index` | `src/mito2/src/series_index/` | Format interfaces (`format.rs`), index writer/searcher, per-SST samples artifacts (`artifact.rs`), bucket planning, catalogs, reconciliation (`maintenance.rs`), and deferred purge |
| `read` | `src/mito2/src/read/` | `ScanRegion`, merge, dedup, projection, streaming |
| `manifest` | `src/mito2/src/manifest/` | `RegionManifestManager`, manifest actions/edits |
| `cache` | `src/mito2/src/cache.rs` | Write/file/page caches |
@@ -66,6 +66,7 @@ filtered `RecordBatch` stream.
and defer new builds when the shared estimate is full. Cleanup still prunes obsolete
range entries and expires series indexes to reclaim published usage. Concurrent builds may
overshoot; closed-region files are not counted. Series handles and the SST purger own file deletion.
- **Series format PoC**: samples are rebuildable local SST sidecars. Optional writer interfaces (`format.rs`) integrate with reconciliation, catalog publication, and deferred purge. Defaults retain the original Parquet writer and disable samples. Prepared sources, read APIs, and concrete new formats are deferred; ordinary scans only use Parquet index artifacts.
- **Series-index coverage** (`series_index/catalog.rs`): `SeriesIndexEntry` stores
compaction-window width and SST summaries keyed by aligned start in both catalogs and Parquet footers.
- **Request types** (`request.rs`): usually tied to proto definitions consumed by `datanode`.
File diff suppressed because it is too large Load Diff
+47
View File
@@ -243,6 +243,10 @@ impl SeriesIndexCoverage {
.version
.series_indexes
.values()
// Unimplemented formats cannot replace authoritative SST candidates.
.filter(|index| {
index.entry().format() == crate::series_index::artifact::ArtifactFormat::Parquet
})
.map(|index| {
let files: HashSet<_> = uncovered
.iter()
@@ -778,6 +782,7 @@ mod tests {
));
let store = env.access_layer.object_store().clone();
let entry = SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: Timestamp::new_millisecond(0),
@@ -964,6 +969,48 @@ mod tests {
}
}
#[tokio::test]
async fn index_merge_releases_permits_before_yielding() {
let (_env, scanner, _pruner) = indexed_scanner().await;
let semaphore = Arc::new(Semaphore::new(1));
let context = scanner.stream_ctx.input.series_index.clone().unwrap();
let streams = (0..3)
.map(|_| {
index_primary_key_stream(
scanner.stream_ctx.clone(),
context.clone(),
scanner.coverage.indexes[0].clone(),
semaphore.clone(),
)
})
.collect();
let merged = merge_primary_key_streams(
streams,
scanner.memory_pool.clone(),
&scanner.metrics_set,
0,
"permit-regression",
)
.unwrap();
let series =
decode_metric_series(merged, scanner.stream_ctx.input.region_metadata().clone())
.unwrap();
let batches = tokio::time::timeout(
std::time::Duration::from_secs(10),
series.try_collect::<Vec<_>>(),
)
.await
.expect("index streams must not hold permits across yield")
.unwrap();
assert_eq!(
(0..1001)
.map(|tsid| MetricSeriesId { table_id: 1, tsid })
.collect::<Vec<_>>(),
batches.into_iter().flatten().collect::<Vec<_>>()
);
assert_eq!(1, semaphore.available_permits());
}
#[tokio::test]
async fn index_read_failure_after_output_is_propagated() {
let (_env, scanner, _pruner) = indexed_scanner().await;
+262 -169
View File
@@ -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;
+8 -5
View File
@@ -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),
})
}
+73 -3
View File
@@ -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(),
+4
View File
@@ -16,9 +16,13 @@
//!
//! Under development. Index files are currently stored on the local filesystem.
pub(crate) mod artifact;
mod bucket;
mod builder;
mod catalog;
pub(crate) mod format;
#[cfg(test)]
mod format_tests;
mod maintenance;
mod purger;
mod searcher;
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Immutable, potentially multi-object format outputs and reader-owned lifetimes.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use store_api::storage::FileId;
use crate::series_index::purger::IndexFilePurger;
use crate::sst::file::RegionFileId;
/// Persisted artifact identifiers. Concrete codecs are added with their implementations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum ArtifactFormat {
#[serde(rename = "parquet")]
Parquet,
#[cfg(test)]
#[serde(rename = "fake-samples")]
FakeSamples,
#[cfg(test)]
#[serde(rename = "fake-index")]
FakeIndex,
}
/// Identifies the exact bytes produced by one backend build.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ArtifactMetadata {
pub(crate) format: ArtifactFormat,
pub(crate) generation: FileId,
/// Every object owned by this build, including metadata and auxiliary files.
pub(crate) objects: Vec<String>,
pub(crate) file_size: u64,
}
/// One samples artifact derived from an immutable Parquet SST.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SamplesEntry {
pub(crate) file_id: FileId,
pub(crate) artifact: ArtifactMetadata,
}
#[derive(Debug, Clone)]
pub(crate) struct SamplesFileHandle(Arc<SamplesFileInner>);
#[derive(Debug)]
struct SamplesFileInner {
file_id: RegionFileId,
entry: SamplesEntry,
retired: AtomicBool,
purger: IndexFilePurger,
}
impl SamplesFileHandle {
pub(crate) fn new(file_id: RegionFileId, entry: SamplesEntry, purger: IndexFilePurger) -> Self {
Self(Arc::new(SamplesFileInner {
file_id,
entry,
retired: AtomicBool::new(false),
purger,
}))
}
pub(crate) fn entry(&self) -> &SamplesEntry {
&self.0.entry
}
pub(crate) fn mark_deleted(&self) {
self.0.retired.store(true, Ordering::Release);
}
}
impl Drop for SamplesFileInner {
fn drop(&mut self) {
if self.retired.load(Ordering::Acquire) {
self.purger
.purge_objects(self.file_id, self.entry.artifact.objects.clone());
}
}
}
+1
View File
@@ -334,6 +334,7 @@ impl SeriesBucket {
.filter_map(|file| file.meta_ref().sequence.map(|sequence| sequence.get()))
.min()?;
Some(SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: self.start,
+110 -4
View File
@@ -35,6 +35,7 @@ use crate::series_index::bucket::SeriesBucket;
use crate::series_index::catalog::{
RangeIndexEntry, SeriesIndexEntry, range_index_path, series_index_path, series_metadata,
};
use crate::series_index::format::SeriesIndexFormat;
use crate::series_index::purger::{IndexFilePurger, IndexFileType, file_operation};
use crate::series_index::version::SeriesIndexFileHandle;
use crate::series_index::{SeriesIndexWriter, SeriesIndexWriterOptions};
@@ -130,6 +131,7 @@ pub(crate) async fn build_series_index(
version: &VersionRef,
bucket: &SeriesBucket,
entry: &SeriesIndexEntry,
backend: Option<&dyn SeriesIndexFormat>,
purger: &IndexFilePurger,
) -> Result<SeriesIndexFileHandle> {
let mut sources = Vec::<BoxedRecordBatchStream>::new();
@@ -180,6 +182,35 @@ pub(crate) async fn build_series_index(
)
};
// TODO(yingwen): Deduplicate update-mode rows before series indexes are used by queries.
if let Some(backend) = backend {
let mut writer = backend
.writer(version.metadata.clone(), store.clone(), entry)
.await?;
let result: Result<()> = async {
while let Some(batch) = visible.try_next().await? {
writer.write(&batch).await?;
}
Ok(())
}
.await;
if let Err(error) = result {
if let Err(cleanup_error) = writer.abort().await {
warn!(cleanup_error; "Failed to abort series-index artifact build");
}
return Err(error);
}
let artifact = writer.finish().await?;
file_operation(IndexFileType::Series, "build", "success");
return Ok(SeriesIndexFileHandle::new(
region.region_id,
SeriesIndexEntry {
file_size: artifact.file_size,
artifact: Some(artifact),
..entry.clone()
},
purger.clone(),
));
}
let path = series_index_path(region.region_id, entry.index_uuid);
let mut writer = SeriesIndexWriter::try_new(
version.metadata.clone(),
@@ -214,6 +245,79 @@ pub(crate) async fn build_series_index(
))
}
/// Converts one immutable SST into an independently rebuildable samples artifact.
pub(crate) async fn build_samples(
store: &ObjectStore,
region: &MitoRegionRef,
version: &VersionRef,
file: FileHandle,
backend: &dyn crate::series_index::format::SamplesFormat,
purger: &IndexFilePurger,
) -> Result<Option<crate::series_index::artifact::SamplesFileHandle>> {
use crate::series_index::artifact::{SamplesEntry, SamplesFileHandle};
let file_id = file.file_id();
let Some((context, mut selection)) = region
.access_layer
.read_sst(file)
.build_reader_input(&mut ReaderMetrics::default())
.await?
else {
return Ok(None);
};
let context = Arc::new(context);
let mapper = FlatProjectionMapper::new(
&version.metadata,
0..version.metadata.column_metadatas.len(),
)?;
let compat = FlatCompatBatch::try_new(&mapper, context.read_format(), false)?;
let generation = store_api::storage::FileId::random();
let prefix = format!(
"{}/samples/{}/{generation}/",
region.region_id.as_u64(),
file_id.file_id()
);
let mut writer = backend
.writer(version.metadata.clone(), store.clone(), &prefix, generation)
.await?;
let result: Result<()> = async {
while let Some((row_group, selection)) = selection.pop_first() {
let reader = context
.reader_builder()
.build(context.build_context(row_group, Some(selection), None))
.await?;
let mut reader = FlatPruneReader::new_with_row_group_reader(
context.clone(),
FlatRowGroupReader::new(context.clone(), reader),
context.pre_filter_mode().skip_fields(),
);
while let Some(batch) = reader.next_batch().await? {
let batch = match &compat {
Some(compat) => compat.compat(batch)?,
None => batch,
};
writer.write(&batch).await?;
}
}
Ok(())
}
.await;
if let Err(error) = result {
if let Err(cleanup) = writer.abort().await {
warn!(cleanup; "Failed to abort samples build");
}
return Err(error);
}
let artifact = writer.finish().await?;
Ok(Some(SamplesFileHandle::new(
file_id,
SamplesEntry {
file_id: file_id.file_id(),
artifact,
},
purger.clone(),
)))
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
@@ -228,7 +332,8 @@ mod tests {
use super::*;
use crate::series_index::bucket::{group_files_into_series_buckets, plan_series_indexes};
use crate::series_index::catalog::{
SeriesIndexCatalog, load_version_control, series_catalog_path, store_catalog,
SeriesIndexCatalog, load_version_control, series_catalog_path, series_index_path,
store_catalog,
};
use crate::series_index::purger::series_index_channel;
use crate::series_index::tests::prepare_region;
@@ -321,6 +426,7 @@ mod tests {
&version,
&bucket,
&entry,
None,
&purger,
)
.await;
@@ -471,7 +577,7 @@ mod tests {
.unwrap()
.unwrap();
}
let _handle = build_series_index(&store, &region, &version, &bucket, &entry, &purger)
let _handle = build_series_index(&store, &region, &version, &bucket, &entry, None, &purger)
.await
.unwrap();
let bytes = store
@@ -540,7 +646,7 @@ mod tests {
};
completed.push(entry.file_id);
}
build_series_index(&store, &region, &version, &bucket, &entry, &purger)
build_series_index(&store, &region, &version, &bucket, &entry, None, &purger)
.await
.map(Some)
}
@@ -746,7 +852,7 @@ mod tests {
.map(|_| ())
} else {
let (purger, _receiver) = series_index_channel(store.clone());
build_series_index(&store, &region, &version, &bucket, &entry, &purger)
build_series_index(&store, &region, &version, &bucket, &entry, None, &purger)
.await
.map(|_| ())
};
+71 -5
View File
@@ -33,6 +33,7 @@ use crate::series_index::version::{
pub(crate) use crate::sst::range_index::range_index_path;
const SERIES_DIR: &str = "series";
const SAMPLES_CATALOG: &str = "samples.json";
const RANGE_CATALOG: &str = "range-index.json";
const SERIES_CATALOG: &str = "series-index.json";
const SERIES_METADATA_KEY: &str = "greptime.series_index";
@@ -60,6 +61,8 @@ pub(crate) struct WindowSequence {
/// relies on this complete-coverage contract, not on `source_file_ids`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SeriesIndexEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) artifact: Option<crate::series_index::artifact::ArtifactMetadata>,
/// Completed size in the catalog; zero in the footer written before completion.
pub(crate) file_size: u64,
pub(crate) index_uuid: FileId,
@@ -83,6 +86,14 @@ pub(crate) struct SeriesIndexEntry {
}
impl SeriesIndexEntry {
/// Entries written before pluggable formats describe the original Parquet layout.
pub(crate) fn format(&self) -> crate::series_index::artifact::ArtifactFormat {
self.artifact.as_ref().map_or(
crate::series_index::artifact::ArtifactFormat::Parquet,
|artifact| artifact.format,
)
}
/// Whether this index completely covers an SST in the query's sequence domain.
pub(crate) fn covers_file(
&self,
@@ -116,6 +127,18 @@ pub(crate) struct RangeIndexCatalog {
pub(crate) indexes: Vec<RangeIndexEntry>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct SamplesCatalog {
pub(crate) entries: Vec<crate::series_index::artifact::SamplesEntry>,
/// Once samples are enabled this single catalog commits all artifact references together.
pub(crate) series: Vec<SeriesIndexEntry>,
pub(crate) ranges: Vec<RangeIndexEntry>,
}
pub(crate) fn samples_catalog_path(region_id: RegionId) -> String {
format!("{}/{SAMPLES_CATALOG}", region_id.as_u64())
}
pub(crate) fn range_catalog_path(region_id: RegionId) -> String {
format!("{}/{RANGE_CATALOG}", region_id.as_u64())
}
@@ -172,6 +195,7 @@ where
pub(crate) async fn delete_catalogs(store: &ObjectStore, region_id: RegionId) {
for path in [
series_catalog_path(region_id),
samples_catalog_path(region_id),
range_catalog_path(region_id),
] {
if let Err(error) = store.delete(&path).await
@@ -194,14 +218,17 @@ pub(crate) async fn load_version_control(
let series = load_catalog::<SeriesIndexCatalog>(store, &series_catalog_path(region_id))
.await
.unwrap_or_default();
let version = SeriesIndexVersion::new(
range
.indexes
let samples = load_catalog::<SamplesCatalog>(store, &samples_catalog_path(region_id)).await;
let (ranges, indexes, sample_entries) = match samples {
Some(catalog) => (catalog.ranges, catalog.series, catalog.entries),
None => (range.indexes, series.indexes, Vec::new()),
};
let mut version = SeriesIndexVersion::new(
ranges
.into_iter()
.map(|entry| (entry.file_id, entry))
.collect(),
series
.indexes
indexes
.into_iter()
.map(|entry| {
(
@@ -211,6 +238,17 @@ pub(crate) async fn load_version_control(
})
.collect(),
);
for entry in sample_entries {
let id = entry.file_id;
version.samples.insert(
id,
crate::series_index::artifact::SamplesFileHandle::new(
crate::sst::file::RegionFileId::new(region_id, id),
entry,
purger.clone(),
),
);
}
let control = SeriesIndexVersionControl::default();
control.publish(std::sync::Arc::new(version));
control
@@ -258,10 +296,37 @@ mod tests {
}
}
#[test]
fn legacy_series_catalog_fixture() {
use crate::series_index::artifact::ArtifactFormat;
let json = include_str!(
"../../../../tests/compatibility/fixtures/series_index/legacy_catalog.json"
);
let catalog: SeriesIndexCatalog = serde_json::from_str(json).unwrap();
assert_eq!(1, catalog.indexes.len());
let entry = &catalog.indexes[0];
assert!(entry.artifact.is_none());
assert_eq!(ArtifactFormat::Parquet, entry.format());
assert_eq!(
serde_json::from_str::<serde_json::Value>(json).unwrap(),
serde_json::to_value(&catalog).unwrap()
);
for (format, name) in [
(ArtifactFormat::Parquet, "parquet"),
(ArtifactFormat::FakeSamples, "fake-samples"),
(ArtifactFormat::FakeIndex, "fake-index"),
] {
let encoded = serde_json::to_value(format).unwrap();
assert_eq!(name, encoded.as_str().unwrap());
assert_eq!(format, serde_json::from_value(encoded).unwrap());
}
}
#[test]
fn coverage_uses_exclusive_time_end_and_inclusive_file_sequences() {
let region_id = RegionId::new(1, 1);
let entry = SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: Timestamp::new_second(1),
@@ -359,6 +424,7 @@ mod tests {
let store = ObjectStore::new(Memory::default()).unwrap();
let region_id = RegionId::new(1, 1);
let entry = SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: Timestamp::new_second(0),
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Storage-format boundaries. Encodings stay outside reconciliation and source scheduling.
use std::fmt::Debug;
use std::sync::Arc;
use async_trait::async_trait;
use datatypes::arrow::record_batch::RecordBatch;
use object_store::ObjectStore;
use store_api::metadata::RegionMetadataRef;
use store_api::storage::FileId;
use crate::error::Result;
use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata};
use crate::series_index::catalog::SeriesIndexEntry;
/// Writers own partial outputs until finish succeeds; abort removes every partial object.
/// A failed finish must clean up its output before returning; implementations also own
/// cleanup on cancellation while a writer is live.
#[async_trait]
pub(crate) trait FormatWriter: Send {
async fn write(&mut self, batch: &RecordBatch) -> Result<()>;
async fn finish(self: Box<Self>) -> Result<ArtifactMetadata>;
async fn abort(self: Box<Self>) -> Result<()>;
}
/// Builds series-index artifacts; read interfaces are deferred.
#[async_trait]
pub(crate) trait SeriesIndexFormat: Debug + Send + Sync {
fn format(&self) -> ArtifactFormat;
async fn writer(
&self,
metadata: RegionMetadataRef,
store: ObjectStore,
entry: &SeriesIndexEntry,
) -> Result<Box<dyn FormatWriter>>;
}
/// Builds samples artifacts; read interfaces are deferred.
#[async_trait]
pub(crate) trait SamplesFormat: Debug + Send + Sync {
fn format(&self) -> ArtifactFormat;
/// The caller supplies a unique prefix. Finish reports all objects owned below it.
async fn writer(
&self,
metadata: RegionMetadataRef,
store: ObjectStore,
prefix: &str,
generation: FileId,
) -> Result<Box<dyn FormatWriter>>;
}
/// Optional writers, captured once per reconciliation pass.
/// Without an index backend, builds use the existing Parquet writer.
#[derive(Debug, Clone, Default)]
pub(crate) struct SeriesFormats {
pub(crate) index: Option<Arc<dyn SeriesIndexFormat>>,
pub(crate) samples: Option<Arc<dyn SamplesFormat>>,
}
+442
View File
@@ -0,0 +1,442 @@
// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Artifact lifecycle tests; fake formats have no codec.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use datatypes::arrow::record_batch::RecordBatch;
use futures::TryStreamExt;
use object_store::ObjectStore;
use object_store::layers::mock::{self, MockLayerBuilder, oio};
use snafu::ResultExt;
use store_api::metadata::RegionMetadataRef;
use store_api::region_engine::RegionEngine;
use store_api::storage::FileId;
use crate::config::MitoConfig;
use crate::error::{OpenDalSnafu, Result, UnexpectedSnafu};
use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata};
use crate::series_index::catalog::{SeriesIndexEntry, load_version_control};
use crate::series_index::format::{FormatWriter, SamplesFormat, SeriesFormats, SeriesIndexFormat};
use crate::series_index::maintenance::reconcile_series_indexes;
use crate::series_index::purger::series_index_channel;
use crate::series_index::tests::prepare_region_with_config;
use crate::test_util::TestEnv;
#[derive(Debug, Default)]
struct FakeSamples {
fail: Arc<AtomicBool>,
}
struct FakeWriter {
store: ObjectStore,
artifact: ArtifactMetadata,
fail: Arc<AtomicBool>,
}
#[async_trait]
impl FormatWriter for FakeWriter {
async fn write(&mut self, _batch: &RecordBatch) -> Result<()> {
if self.fail.load(Ordering::Relaxed) {
return UnexpectedSnafu {
reason: "injected samples write failure",
}
.fail();
}
Ok(())
}
async fn finish(self: Box<Self>) -> Result<ArtifactMetadata> {
Ok(self.artifact)
}
async fn abort(self: Box<Self>) -> Result<()> {
for path in &self.artifact.objects {
self.store.delete(path).await.context(OpenDalSnafu)?;
}
Ok(())
}
}
#[async_trait]
impl SamplesFormat for FakeSamples {
fn format(&self) -> ArtifactFormat {
ArtifactFormat::FakeSamples
}
async fn writer(
&self,
_metadata: RegionMetadataRef,
store: ObjectStore,
prefix: &str,
generation: FileId,
) -> Result<Box<dyn FormatWriter>> {
let objects = vec![format!("{prefix}data"), format!("{prefix}directory")];
for path in &objects {
store.write(path, "fake").await.context(OpenDalSnafu)?;
}
Ok(Box::new(FakeWriter {
store,
artifact: ArtifactMetadata {
format: self.format(),
generation,
objects,
file_size: 8,
},
fail: self.fail.clone(),
}))
}
}
#[tokio::test]
async fn format_skeleton_build_reopen_and_retire() {
let mut env = TestEnv::with_prefix("format-skeleton").await;
let (engine, region) = prepare_region_with_config(
&mut env,
&[1000, 2000],
MitoConfig {
experimental_enable_series_index: true,
experimental_series_index_maintenance_interval: Duration::from_secs(3600),
min_compaction_interval: Duration::from_secs(3600),
..Default::default()
},
)
.await;
let fake = Arc::new(FakeSamples::default());
*region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats {
samples: Some(fake.clone()),
..Default::default()
});
let store = region.series_index_store.clone().unwrap();
let (purger, mut purges) = series_index_channel(store.clone());
let stats = reconcile_series_indexes(
0,
store.clone(),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
.await
.unwrap();
assert_eq!(2, stats.built_samples);
let snapshot = region.series_index_version();
assert_eq!(2, snapshot.samples.len());
let reopened = load_version_control(&store, region.region_id, &purger).await;
assert_eq!(2, reopened.current().samples.len());
assert!(snapshot.disk_usage() >= 16);
let stats = reconcile_series_indexes(
0,
store.clone(),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
.await
.unwrap();
assert_eq!(0, stats.built_samples);
// Rebuilding a missing sidecar replaces its generation but retains old readers' files.
let old = snapshot.samples.values().next().unwrap().clone();
store
.delete(&old.entry().artifact.objects[0])
.await
.unwrap();
let stats = reconcile_series_indexes(
0,
store.clone(),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
.await
.unwrap();
assert_eq!(1, stats.built_samples);
assert!(purges.try_recv().is_err());
drop(snapshot);
assert!(purges.try_recv().is_err());
drop(old);
let request = purges.try_recv().unwrap();
assert_eq!(2, request.objects.unwrap().len());
engine.stop().await.unwrap();
}
#[tokio::test]
async fn format_skeleton_abort_removes_all_objects() {
let mut env = TestEnv::with_prefix("format-abort").await;
let (engine, region) = crate::series_index::tests::prepare_region(&mut env).await;
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
let (purger, _receiver) = series_index_channel(store.clone());
let version = region.version();
let file = version
.ssts
.levels()
.iter()
.flat_map(|l| l.files())
.next()
.unwrap()
.clone();
let fake = FakeSamples::default();
fake.fail.store(true, Ordering::Relaxed);
assert!(
crate::series_index::builder::build_samples(
&store, &region, &version, file, &fake, &purger
)
.await
.is_err()
);
assert!(
store
.list_with(&format!("{}/samples/", region.region_id.as_u64()))
.recursive(true)
.await
.unwrap()
.iter()
.all(|entry| entry.metadata().is_dir())
);
assert!(region.series_index_version().samples.is_empty());
engine.stop().await.unwrap();
}
#[derive(Debug, Default)]
struct FakeIndex;
#[async_trait]
impl SeriesIndexFormat for FakeIndex {
fn format(&self) -> ArtifactFormat {
ArtifactFormat::FakeIndex
}
async fn writer(
&self,
metadata: RegionMetadataRef,
store: ObjectStore,
entry: &SeriesIndexEntry,
) -> Result<Box<dyn FormatWriter>> {
let path = format!(
"{}/series/{}.fake",
metadata.region_id.as_u64(),
entry.index_uuid
);
store.write(&path, "fake").await.context(OpenDalSnafu)?;
Ok(Box::new(FakeWriter {
store,
artifact: ArtifactMetadata {
format: self.format(),
generation: entry.index_uuid,
objects: vec![path],
file_size: 4,
},
fail: Arc::new(AtomicBool::new(false)),
}))
}
}
#[tokio::test]
async fn format_index_rebuild_is_scoped_and_scans_fall_back_to_ssts() {
let mut env = TestEnv::with_prefix("format-index").await;
// Index builds require four SSTs per bucket.
let timestamps = [1000, 2000, 3000, 4000, 101000, 102000, 103000, 104000];
let (engine, region) = prepare_region_with_config(
&mut env,
&timestamps,
MitoConfig {
experimental_enable_series_index: true,
experimental_series_scan_v2: true,
experimental_series_index_maintenance_interval: Duration::from_secs(3600),
min_compaction_interval: Duration::from_secs(3600),
..Default::default()
},
)
.await;
*region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats {
index: Some(Arc::new(FakeIndex)),
..Default::default()
});
let store = region.series_index_store.clone().unwrap();
let (purger, mut purges) = series_index_channel(store.clone());
let reconcile = || {
reconcile_series_indexes(
0,
store.clone(),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
};
assert_eq!(2, reconcile().await.unwrap().built_series);
let before = region.series_index_version();
assert!(
before
.series_indexes
.values()
.all(|h| h.entry().format() == ArtifactFormat::FakeIndex)
);
assert_eq!(0, reconcile().await.unwrap().built_series);
let scanner = engine
.scanner(
region.region_id,
store_api::storage::ScanRequest {
distribution: Some(store_api::storage::TimeSeriesDistribution::PerSeries),
..Default::default()
},
)
.await
.unwrap();
let batches = scanner
.scan()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(
timestamps.len(),
batches.iter().map(|batch| batch.num_rows()).sum::<usize>()
);
drop(scanner);
let missing = before.series_indexes.values().next().unwrap();
store
.delete(&missing.entry().artifact.as_ref().unwrap().objects[0])
.await
.unwrap();
assert_eq!(1, reconcile().await.unwrap().built_series);
let after = region.series_index_version();
assert_eq!(
1,
after
.series_indexes
.keys()
.filter(|id| before.series_indexes.contains_key(id))
.count()
);
assert!(purges.try_recv().is_err());
drop(before);
assert!(purges.try_recv().is_ok());
// Disabling the injected writer restores ordinary Parquet artifacts without a read adapter.
*region.series_index_version_control.formats.write().unwrap() =
Arc::new(SeriesFormats::default());
assert_eq!(2, reconcile().await.unwrap().built_series);
assert!(
region
.series_index_version()
.series_indexes
.values()
.all(|h| h.entry().artifact.is_none())
);
assert_eq!(0, reconcile().await.unwrap().built_series);
engine.stop().await.unwrap();
}
struct FailedCatalogWriter(oio::Writer);
impl mock::Write for FailedCatalogWriter {
async fn write(&mut self, buffer: mock::Buffer) -> mock::Result<()> {
self.0.write(buffer).await
}
async fn close(&mut self) -> mock::Result<mock::Metadata> {
Err(mock::Error::new(
mock::ErrorKind::Unexpected,
"injected artifact catalog failure",
))
}
async fn abort(&mut self) -> mock::Result<()> {
self.0.abort().await
}
}
#[tokio::test]
async fn format_catalog_failure_preserves_published_artifacts() {
let mut env = TestEnv::with_prefix("format-catalog-failure").await;
let (engine, region) = crate::series_index::tests::prepare_region(&mut env).await;
*region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats {
index: Some(Arc::new(FakeIndex)),
samples: Some(Arc::new(FakeSamples::default())),
});
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
let (purger, mut purges) = series_index_channel(store.clone());
reconcile_series_indexes(
0,
store.clone(),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
.await
.unwrap();
let before = region.series_index_version();
let catalog_path = crate::series_index::catalog::samples_catalog_path(region.region_id);
let catalog = store.read(&catalog_path).await.unwrap().to_bytes();
let old = before.samples.values().next().unwrap();
store
.delete(&old.entry().artifact.objects[0])
.await
.unwrap();
let layer = MockLayerBuilder::default()
.writer_factory(Arc::new(|path, _, inner| {
if path.ends_with("samples.json") {
Box::new(FailedCatalogWriter(inner))
} else {
inner
}
}))
.build()
.unwrap();
let error = reconcile_series_indexes(
0,
store.clone().layer(layer),
region.clone(),
Duration::from_secs(100),
0,
purger.clone(),
false,
true,
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("injected artifact catalog failure"));
assert!(Arc::ptr_eq(&before, &region.series_index_version()));
assert_eq!(catalog, store.read(&catalog_path).await.unwrap().to_bytes());
let cleanup = purges.try_recv().unwrap().objects.unwrap();
assert_eq!(2, cleanup.len());
assert!(
cleanup
.iter()
.all(|path| !old.entry().artifact.objects.contains(path))
);
assert!(purges.try_recv().is_err());
let reopened = load_version_control(&store, region.region_id, &purger).await;
assert_eq!(
old.entry(),
reopened.current().samples[&old.entry().file_id].entry()
);
engine.stop().await.unwrap();
}
+141 -10
View File
@@ -20,6 +20,7 @@ use std::time::{Duration, Instant};
use common_telemetry::{debug, info};
use object_store::ObjectStore;
use snafu::ResultExt;
use store_api::storage::{FileId, RegionId};
use crate::error::Result;
@@ -40,16 +41,23 @@ use crate::series_index::version::{SeriesIndexFileHandle, SeriesIndexVersion};
/// Retires newly completed series files unless their snapshot is published.
#[derive(Default)]
struct UnpublishedSeriesFiles(Vec<SeriesIndexFileHandle>);
struct UnpublishedSeriesFiles(
Vec<SeriesIndexFileHandle>,
Vec<crate::series_index::artifact::SamplesFileHandle>,
);
impl UnpublishedSeriesFiles {
fn disarm(&mut self) {
self.0.clear();
self.1.clear();
}
}
impl Drop for UnpublishedSeriesFiles {
fn drop(&mut self) {
for handle in &self.1 {
handle.mark_deleted();
}
for handle in &self.0 {
handle.mark_deleted();
}
@@ -58,6 +66,8 @@ impl Drop for UnpublishedSeriesFiles {
#[derive(Debug, Default)]
pub(crate) struct ReconcileStats {
pub(crate) built_samples: usize,
pub(crate) removed_samples: usize,
pub(crate) source_files: usize,
pub(crate) built_range: usize,
pub(crate) built_series: usize,
@@ -69,7 +79,13 @@ pub(crate) struct ReconcileStats {
impl ReconcileStats {
fn changed(&self) -> bool {
self.built_range + self.built_series + self.removed_range + self.removed_series > 0
self.built_samples
+ self.removed_samples
+ self.built_range
+ self.built_series
+ self.removed_range
+ self.removed_series
> 0
}
}
@@ -201,12 +217,43 @@ async fn build_index_version(
}
(false, _) => Vec::new(),
};
let plan = plan_series_indexes(
buckets,
current.index_buckets.clone(),
version.options.ttl,
now_ms,
let formats = region
.series_index_version_control
.formats
.read()
.unwrap()
.clone();
let selected_format = formats.index.as_ref().map_or(
crate::series_index::artifact::ArtifactFormat::Parquet,
|backend| backend.format(),
);
let mut unusable = HashSet::new();
for handle in current.series_indexes.values() {
let entry = handle.entry();
let mut reusable = entry.format() == selected_format;
// Legacy Parquet indexes retain their existing maintenance behavior.
if reusable && let Some(artifact) = &entry.artifact {
for object in &artifact.objects {
reusable &= store
.exists(object)
.await
.context(crate::error::OpenDalSnafu)?;
}
}
if !reusable {
unusable.insert(entry.index_uuid);
}
}
let mut index_buckets = current.index_buckets.clone();
for bucket in index_buckets.values_mut() {
if bucket.index_ids.iter().any(|id| unusable.contains(id)) {
// Force replacement only for the affected bucket. Retain its geometry and
// handles until a replacement is built and its catalog is published.
bucket.compaction_window_secs = 0;
bucket.window_sequences.clear();
}
}
let plan = plan_series_indexes(buckets, index_buckets, version.options.ttl, now_ms);
stats.computed_buckets = plan.computed_buckets;
stats.skipped_buckets = plan.skipped_buckets;
let mut next = prune_index_version(&current, &visible, &plan.expired_index_ids, &mut stats);
@@ -218,6 +265,7 @@ async fn build_index_version(
}
if plan.builds.is_empty()
&& next.is_none()
&& formats.samples.is_none()
&& (!enable_range_index || current.range_indexes.len() == visible.len())
{
return Ok((None, stats));
@@ -225,13 +273,50 @@ async fn build_index_version(
let SeriesIndexVersion {
mut range_indexes,
mut series_indexes,
mut samples,
..
} = next.unwrap_or_else(|| SeriesIndexVersion {
samples: current.samples.clone(),
range_indexes: current.range_indexes.clone(),
series_indexes: current.series_indexes.clone(),
index_buckets: Default::default(),
});
let index_buckets = plan.index_buckets;
if let Some(backend) = &formats.samples {
for file in &files {
let id = file.file_id().file_id();
let reusable = if let Some(handle) = samples.get(&id) {
let artifact = &handle.entry().artifact;
let mut exists = artifact.format == backend.format();
for path in &artifact.objects {
exists &= store
.exists(path)
.await
.context(crate::error::OpenDalSnafu)?;
}
exists
} else {
false
};
if reusable {
continue;
}
if let Some(handle) = crate::series_index::builder::build_samples(
store,
region,
version,
file.clone(),
backend.as_ref(),
purger,
)
.await?
{
unpublished.1.push(handle.clone());
samples.insert(id, handle);
stats.built_samples += 1;
}
}
}
for id in &plan.superseded_index_ids {
series_indexes.remove(id);
}
@@ -246,8 +331,16 @@ async fn build_index_version(
range_indexes.insert(entry.file_id, entry);
}
}
let series_handle =
build_series_index(store, region, version, &bucket, &expected, purger).await?;
let series_handle = build_series_index(
store,
region,
version,
&bucket,
&expected,
formats.index.as_deref(),
purger,
)
.await?;
unpublished.0.push(series_handle.clone());
stats.built_series += 1;
series_indexes.insert(expected.index_uuid, series_handle);
@@ -276,6 +369,7 @@ async fn build_index_version(
return Ok((None, stats));
}
let next = SeriesIndexVersion {
samples,
range_indexes,
series_indexes,
index_buckets,
@@ -293,7 +387,8 @@ fn prune_index_version(
expired_index_ids: &[FileId],
stats: &mut ReconcileStats,
) -> Option<SeriesIndexVersion> {
if current.range_indexes.keys().all(|id| visible.contains(id))
if current.samples.keys().all(|id| visible.contains(id))
&& current.range_indexes.keys().all(|id| visible.contains(id))
&& expired_index_ids
.iter()
.all(|id| !current.series_indexes.contains_key(id))
@@ -301,10 +396,13 @@ fn prune_index_version(
return None;
}
let mut next = SeriesIndexVersion {
samples: current.samples.clone(),
range_indexes: current.range_indexes.clone(),
series_indexes: current.series_indexes.clone(),
index_buckets: current.index_buckets.clone(),
};
next.samples.retain(|id, _| visible.contains(id));
stats.removed_samples = current.samples.len() - next.samples.len();
next.range_indexes
.retain(|file_id, _| visible.contains(file_id));
for id in expired_index_ids {
@@ -322,6 +420,31 @@ async fn persist_index_catalogs(
next: &SeriesIndexVersion,
stats: &ReconcileStats,
) -> Result<()> {
let samples_path = crate::series_index::catalog::samples_catalog_path(region_id);
if !next.samples.is_empty()
|| stats.removed_samples > 0
|| store
.exists(&samples_path)
.await
.context(crate::error::OpenDalSnafu)?
{
// A single object write commits all references. A failed write leaves the prior
// catalog usable while the unpublished-output guard retires the new artifacts.
return store_catalog(
store,
&samples_path,
&crate::series_index::catalog::SamplesCatalog {
entries: next.samples.values().map(|h| h.entry().clone()).collect(),
series: next
.series_indexes
.values()
.map(|h| h.entry().clone())
.collect(),
ranges: next.range_indexes.values().copied().collect(),
},
)
.await;
}
if stats.built_range + stats.removed_range > 0 {
let mut range_entries = next.range_indexes.values().copied().collect::<Vec<_>>();
range_entries
@@ -364,6 +487,13 @@ async fn persist_index_catalogs(
/// Publishes the snapshot and retires series files absent from the new version.
fn publish_index_version(region: &MitoRegionRef, next: Arc<SeriesIndexVersion>) {
let previous = region.series_index_version_control.publish(next.clone());
for (id, handle) in &previous.samples {
if next.samples.get(id).is_none_or(|next| {
next.entry().artifact.generation != handle.entry().artifact.generation
}) {
handle.mark_deleted();
}
}
for (id, handle) in &previous.series_indexes {
if !next.series_indexes.contains_key(id) {
// Purge only after readers release their retained handles.
@@ -405,6 +535,7 @@ mod tests {
SeriesIndexFileHandle::new(
RegionId::new(1, 1),
SeriesIndexEntry {
artifact: None,
index_uuid: series_id,
file_size: 20,
bucket_start: Timestamp::new_second(0),
+35 -14
View File
@@ -39,9 +39,10 @@ impl IndexFileType {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub(crate) struct PurgeRequest {
pub(crate) file_id: RegionFileId,
pub(crate) objects: Option<Vec<String>>,
}
#[derive(Clone)]
@@ -57,6 +58,13 @@ impl Debug for IndexFilePurger {
}
impl IndexFilePurger {
pub(crate) fn purge_objects(&self, file_id: RegionFileId, objects: Vec<String>) {
self.purge(PurgeRequest {
file_id,
objects: Some(objects),
});
}
pub(crate) fn purge(&self, request: PurgeRequest) {
if let Err(error) = self.sender.send(request) {
let store = self.store.clone();
@@ -87,17 +95,24 @@ pub(crate) async fn run_index_purge_task(
}
async fn purge_file(store: &ObjectStore, request: PurgeRequest) {
let path = series_index_path(request.file_id.region_id(), request.file_id.file_id());
match store.delete(&path).await {
Ok(()) => {
file_operation(IndexFileType::Series, "delete", "success");
}
Err(error) if error.kind() == ErrorKind::NotFound => {
file_operation(IndexFileType::Series, "delete", "success");
}
Err(error) => {
file_operation(IndexFileType::Series, "delete", "failure");
warn!(error; "Failed to delete series index, index_type: {}, path: {}, phase: deletion", IndexFileType::Series.as_str(), path);
let objects = request.objects.unwrap_or_else(|| {
vec![series_index_path(
request.file_id.region_id(),
request.file_id.file_id(),
)]
});
for path in objects {
match store.delete(&path).await {
Ok(()) => {
file_operation(IndexFileType::Series, "delete", "success");
}
Err(error) if error.kind() == ErrorKind::NotFound => {
file_operation(IndexFileType::Series, "delete", "success");
}
Err(error) => {
file_operation(IndexFileType::Series, "delete", "failure");
warn!(error; "Failed to delete series index, index_type: {}, path: {}, phase: deletion", IndexFileType::Series.as_str(), path);
}
}
}
}
@@ -129,7 +144,10 @@ mod tests {
let path = series_index_path(file_id.region_id(), file_id.file_id());
store.write(&path, "index").await.unwrap();
paths.push(path);
purger.purge(PurgeRequest { file_id });
purger.purge(PurgeRequest {
file_id,
objects: None,
});
}
drop(purger);
tokio::time::timeout(
@@ -151,7 +169,10 @@ mod tests {
let file_id = RegionFileId::new(RegionId::new(1, 1), FileId::random());
let path = series_index_path(file_id.region_id(), file_id.file_id());
store.write(&path, "index").await.unwrap();
purger.purge(PurgeRequest { file_id });
purger.purge(PurgeRequest {
file_id,
objects: None,
});
tokio::time::timeout(Duration::from_secs(10), async {
while store.exists(&path).await.unwrap() {
tokio::task::yield_now().await;
+1
View File
@@ -312,6 +312,7 @@ mod tests {
) -> (SeriesIndexFileHandle, UnboundedReceiver<PurgeRequest>) {
let (purger, receiver) = series_index_channel(store.clone());
let entry = SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: common_time::Timestamp::new_second(0),
+3 -2
View File
@@ -63,7 +63,7 @@ pub(crate) async fn prepare_region_with_timestamps(
.await
}
async fn prepare_region_with_config(
pub(crate) async fn prepare_region_with_config(
env: &mut TestEnv,
timestamps: &[i64],
config: MitoConfig,
@@ -726,7 +726,7 @@ async fn test_failed_reconcile_retires_only_unpublished_series() {
.unwrap();
ranges.insert(entry.file_id, entry);
}
let handle = build_series_index(store, &region, &version, bucket, entry, purger)
let handle = build_series_index(store, &region, &version, bucket, entry, None, purger)
.await
.unwrap();
let reused_path = series_index_path(region.region_id, entry.index_uuid);
@@ -891,6 +891,7 @@ async fn test_drop_catalogs_and_retained_snapshot_after_task_stop() {
let store = ObjectStore::new(Memory::default()).unwrap();
let region_id = RegionId::new(1, 1);
let entry = SeriesIndexEntry {
artifact: None,
file_size: 0,
index_uuid: FileId::random(),
bucket_start: Timestamp::new_second(0),
+22 -5
View File
@@ -82,9 +82,15 @@ struct SeriesIndexFileHandleInner {
impl Drop for SeriesIndexFileHandleInner {
fn drop(&mut self) {
if self.deleted.load(Ordering::Acquire) {
self.purger.purge(PurgeRequest {
file_id: self.file_id,
});
if let Some(artifact) = &self.entry.artifact {
self.purger
.purge_objects(self.file_id, artifact.objects.clone());
} else {
self.purger.purge(PurgeRequest {
file_id: self.file_id,
objects: None,
});
}
}
}
}
@@ -94,6 +100,7 @@ impl Drop for SeriesIndexFileHandleInner {
pub(crate) struct SeriesIndexVersion {
/// Range indexes for visible SSTs; reconciliation removes IDs absent from its SST snapshot.
/// Physical deletion is independently handled by the SST file purger.
pub(crate) samples: HashMap<FileId, crate::series_index::artifact::SamplesFileHandle>,
pub(crate) range_indexes: HashMap<FileId, RangeIndexEntry>,
pub(crate) series_indexes: HashMap<FileId, SeriesIndexFileHandle>,
pub(crate) index_buckets: BTreeMap<Timestamp, IndexBucket>,
@@ -110,6 +117,7 @@ impl SeriesIndexVersion {
IndexBucket::from_entry(handle.entry()).insert_into(&mut index_buckets);
}
Self {
samples: Default::default(),
range_indexes,
series_indexes,
index_buckets,
@@ -118,10 +126,15 @@ impl SeriesIndexVersion {
/// Approximate installed usage; old snapshots and unpublished outputs are excluded.
pub(crate) fn disk_usage(&self) -> u64 {
self.range_indexes
self.samples
.values()
.map(|entry| entry.file_size)
.map(|handle| handle.entry().artifact.file_size)
.sum::<u64>()
+ self
.range_indexes
.values()
.map(|entry| entry.file_size)
.sum::<u64>()
+ self
.series_indexes
.values()
@@ -130,6 +143,9 @@ impl SeriesIndexVersion {
}
fn mark_all_deleted(&self) {
self.samples
.values()
.for_each(|handle| handle.mark_deleted());
self.series_indexes
.values()
.for_each(SeriesIndexFileHandle::mark_deleted);
@@ -140,6 +156,7 @@ impl SeriesIndexVersion {
#[derive(Debug, Default)]
pub(crate) struct SeriesIndexVersionControl {
current: RwLock<Arc<SeriesIndexVersion>>,
pub(crate) formats: RwLock<Arc<crate::series_index::format::SeriesFormats>>,
}
impl SeriesIndexVersionControl {
+110
View File
@@ -0,0 +1,110 @@
# promql — Agent & Contributor Guide
Navigation map for `src/promql`. Repo-wide invariants:
[`.agents/architecture-invariants.md`](../../.agents/architecture-invariants.md).
`promql` owns PromQL evaluation: the series/evaluated data model, range and
instant evaluation, function kernels, vector matching, and the PromQL extension
operators. Statement-to-plan translation lives in `src/query/src/promql/`.
## Module map
| Area | Path | Entry point |
| --- | --- | --- |
| Data model | `src/promql/src/grid_series.rs`, `src/promql/src/evaluated.rs` | Grid series, evaluated chunks |
| Evaluation | `src/promql/src/range_eval.rs`, `src/promql/src/instant_eval.rs` | Range and instant evaluation |
| Kernels | `src/promql/src/functions.rs`, `src/promql/src/functions/` | PromQL functions |
| Matching | `src/promql/src/vector_match.rs` | Binary vector matching |
| Extension operators | `src/promql/src/extension_plan.rs`, `src/promql/src/extension_plan/` | Logical nodes and execution plans |
| Native model | `src/promql/src/native/` | Compiled tree, chunks, display, metrics |
## Two evaluation paths, and their status
The easiest thing to get wrong here. Both exist; only one runs.
- **Extension operators are live.** `PromPlanner` builds `SeriesDivide`,
`SeriesNormalize`, `RangeManipulate`, `InstantManipulate`, `EmptyMetric` and
`UnionDistinctOn`; `PromExtensionPlanner`
(`src/promql/src/extension_plan/planner.rs`) maps each logical node to its
execution plan, one branch per node, `None` for anything else.
- **Nothing plans into the native model yet.** `src/query/src/promql/native_compile.rs`
compiles a statement into a `NativeTree`, but no planner calls it, so a tree
built here reaches no execution. `src/promql/src/native/` holds the tree, the
chunk protocol, display and metrics, and — once a runtime lands — the driver
that runs a tree node by node. Compiling and running are separate concerns
owned by separate files; do not assume a tree is executable because it
compiles.
## Change coupling
- **An extension node reaches a datanode only if three things line up**: a payload
branch in `src/common/substrait/src/extension_serializer.rs`, a physical branch
in `PromExtensionPlanner`, and a `Commutativity::Commutative` arm in
`check_extension_plan` (`src/query/src/dist_plan/commutativity.rs`). Missing the
third is silent: the node falls through to `Unsupported`, the distributed
boundary is placed above it, and it never reaches a region — with no error.
- A new logical node also needs its physical planner reachable from
`DfQueryPlanner::create_physical_plan`, assembled in
`src/query/src/query_engine/state.rs`.
- User-visible changes need planner coverage plus a sqlness case under
`tests/cases/standalone/common/promql/`.
## Gotchas
- **One operation, two implementations; build on the live one.** `InstantManipulate`
routes to `InstantManipulateExec`. The native alternative
`NativeInstantManipulateExec` is crate-private and
`InstantManipulate::try_to_native_execution_plan` has no caller on any branch —
its doc is written for a caller that does not exist.
- **A native window requires non-interleaved input.** Only `Contiguous` and
`KeyThenTime` are accepted; `Interleaved` is refused when compiled.
- **A series closes exactly once**, on the chunk that says so — per input series,
not per label set. A node that rewrites labels can hand two input series over
under one label set, and each closes on its own chunk, so a label set may be
closed more than once; a merge above them must not read the second close as a
defect. That closing chunk is the only way a consumer distinguishes "nothing more
here" from "no more input": dropping it leaves a series open, and a child that
merges regions must recompute finality globally rather than pass a region-local
flag through.
- **Raw-frame labels come from the binding, not the schema.** A raw series frame
carries an opaque encoded primary key while its schema metadata describes only
the format: `greptime.series.layout`, `.content`, `.labels` and `.time`, plus
the producer ordering the adapter adds. Nothing in that metadata binds the key
to a decode — the frame decoder compares those format keys and the field list,
and never a label identity. `unpack_raw_series` therefore requires a
caller-supplied `RawLabelSource`: decode with the source that was actually bound
when the stream was produced, because the frame schema does not replace that
binding. The metadata describes format, not label identity.
- **A wrong label binding is silently wrong, not an error.** Storage validates the
binding it is given (metadata identity, tag-ness, name matches, no duplicates)
but cannot tell whether that set is the one the query meant, and the metric name
is not part of that check.
## Testing
```bash
cargo nextest run -p promql
cargo nextest run -p query # planner and native-compile changes
cargo sqlness bare -t <case> # PromQL cases live under tests/cases/standalone/common/promql/
```
## Not wired up yet
The storage side of the raw path exists; what is missing is planner wiring. Both
gaps below are structural, not bugs.
- **The native tree has no planner entry point.** `native_compile::compile` has no
caller, and a `NativeTree` becomes a physical plan only once something builds it
while it still holds the AST and the selector binding — the point where the
matchers, `__field__` and the lookback are still available. Compiling later, from
a plan, loses them.
- **No planner reaches a raw scan.** mito2 implements
`RegionScanner::raw_series_schema` (`mito2/src/read/series_scan.rs`), so
`RegionScanExec::try_to_series_scan` can build `SeriesScanExec` from a prepared
scanner; the trait's `Ok(None)` default only refuses engines that have not
implemented it. What is missing is the planner side: nothing selects that
conversion, and `SeriesScanExec::children()` is empty, so wrapping a
`RegionScanExec` before partition preparation leaves `ParallelizeScan` unable to
assign partitions. Either give the partitioner a child it can see, or convert to
the native leaf only after partition preparation — the second is the contract
`RegionScanExec::try_to_series_scan` documents.
+1
View File
@@ -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
+27 -28
View File
@@ -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))
}
+91 -2
View File
@@ -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]
+4 -1
View File
@@ -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;
+52
View File
@@ -74,6 +74,58 @@ pub fn map_compare_series(
})
}
/// `max_of(a, b)` and `min_of(a, b)`, which Prometheus evaluates with Go's `math.Max` and
/// `math.Min`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScalarFunction {
MaxOf,
MinOf,
}
impl ScalarFunction {
/// Go's `math.Max` / `math.Min`: an infinity of the function's own sign wins over NaN, NaN
/// over any other value, and `+0` is greater than `-0`.
pub fn apply(self, a: f64, b: f64) -> f64 {
let (wins, signed_zero_wins) = match self {
Self::MaxOf => (f64::INFINITY, false),
Self::MinOf => (f64::NEG_INFINITY, true),
};
if a == wins || b == wins {
wins
} else if a.is_nan() || b.is_nan() {
f64::NAN
} else if a == 0.0 && a == b {
if a.is_sign_negative() == signed_zero_wins {
a
} else {
b
}
} else if (a > b) == (self == Self::MaxOf) {
a
} else {
b
}
}
}
/// Applies `function` to two time-varying scalars, aligned by absolute grid index; a point where
/// either has no value has no result. Both operands must be unlabelled single-field series.
pub fn map_scalar_function(
schema: &Arc<SeriesSchema>,
function: ScalarFunction,
left: &Series,
right: &Series,
) -> Result<Option<Series>> {
validate_scalar(schema, left)?;
map_grid_scalar(
schema,
left,
ScalarSeriesOperand::Right(right),
left.metric.clone(),
|left, right, _| Some(function.apply(left, right)),
)
}
/// Calls `calculate(left, right, vector_sample)` at each index where both operands exist.
fn map_grid_scalar(
schema: &Arc<SeriesSchema>,
+65
View File
@@ -0,0 +1,65 @@
# Generated by promql-compat cmd/narthex-golden-scalarfn built for amd64: `a b math.Max(a,b) math.Min(a,b)` as hex bits.
0 0 0 0
0 8000000000000000 0 8000000000000000
0 3ff0000000000000 3ff0000000000000 0
0 bff0000000000000 0 bff0000000000000
0 4004000000000000 4004000000000000 0
0 7ff0000000000000 7ff0000000000000 0
0 fff0000000000000 0 fff0000000000000
0 7ff8000000000001 7ff8000000000001 7ff8000000000001
8000000000000000 0 0 8000000000000000
8000000000000000 8000000000000000 8000000000000000 8000000000000000
8000000000000000 3ff0000000000000 3ff0000000000000 8000000000000000
8000000000000000 bff0000000000000 8000000000000000 bff0000000000000
8000000000000000 4004000000000000 4004000000000000 8000000000000000
8000000000000000 7ff0000000000000 7ff0000000000000 8000000000000000
8000000000000000 fff0000000000000 8000000000000000 fff0000000000000
8000000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001
3ff0000000000000 0 3ff0000000000000 0
3ff0000000000000 8000000000000000 3ff0000000000000 8000000000000000
3ff0000000000000 3ff0000000000000 3ff0000000000000 3ff0000000000000
3ff0000000000000 bff0000000000000 3ff0000000000000 bff0000000000000
3ff0000000000000 4004000000000000 4004000000000000 3ff0000000000000
3ff0000000000000 7ff0000000000000 7ff0000000000000 3ff0000000000000
3ff0000000000000 fff0000000000000 3ff0000000000000 fff0000000000000
3ff0000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001
bff0000000000000 0 0 bff0000000000000
bff0000000000000 8000000000000000 8000000000000000 bff0000000000000
bff0000000000000 3ff0000000000000 3ff0000000000000 bff0000000000000
bff0000000000000 bff0000000000000 bff0000000000000 bff0000000000000
bff0000000000000 4004000000000000 4004000000000000 bff0000000000000
bff0000000000000 7ff0000000000000 7ff0000000000000 bff0000000000000
bff0000000000000 fff0000000000000 bff0000000000000 fff0000000000000
bff0000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001
4004000000000000 0 4004000000000000 0
4004000000000000 8000000000000000 4004000000000000 8000000000000000
4004000000000000 3ff0000000000000 4004000000000000 3ff0000000000000
4004000000000000 bff0000000000000 4004000000000000 bff0000000000000
4004000000000000 4004000000000000 4004000000000000 4004000000000000
4004000000000000 7ff0000000000000 7ff0000000000000 4004000000000000
4004000000000000 fff0000000000000 4004000000000000 fff0000000000000
4004000000000000 7ff8000000000001 7ff8000000000001 7ff8000000000001
7ff0000000000000 0 7ff0000000000000 0
7ff0000000000000 8000000000000000 7ff0000000000000 8000000000000000
7ff0000000000000 3ff0000000000000 7ff0000000000000 3ff0000000000000
7ff0000000000000 bff0000000000000 7ff0000000000000 bff0000000000000
7ff0000000000000 4004000000000000 7ff0000000000000 4004000000000000
7ff0000000000000 7ff0000000000000 7ff0000000000000 7ff0000000000000
7ff0000000000000 fff0000000000000 7ff0000000000000 fff0000000000000
7ff0000000000000 7ff8000000000001 7ff0000000000000 7ff8000000000001
fff0000000000000 0 0 fff0000000000000
fff0000000000000 8000000000000000 8000000000000000 fff0000000000000
fff0000000000000 3ff0000000000000 3ff0000000000000 fff0000000000000
fff0000000000000 bff0000000000000 bff0000000000000 fff0000000000000
fff0000000000000 4004000000000000 4004000000000000 fff0000000000000
fff0000000000000 7ff0000000000000 7ff0000000000000 fff0000000000000
fff0000000000000 fff0000000000000 fff0000000000000 fff0000000000000
fff0000000000000 7ff8000000000001 7ff8000000000001 fff0000000000000
7ff8000000000001 0 7ff8000000000001 7ff8000000000001
7ff8000000000001 8000000000000000 7ff8000000000001 7ff8000000000001
7ff8000000000001 3ff0000000000000 7ff8000000000001 7ff8000000000001
7ff8000000000001 bff0000000000000 7ff8000000000001 7ff8000000000001
7ff8000000000001 4004000000000000 7ff8000000000001 7ff8000000000001
7ff8000000000001 7ff0000000000000 7ff0000000000000 7ff8000000000001
7ff8000000000001 fff0000000000000 7ff8000000000001 fff0000000000000
7ff8000000000001 7ff8000000000001 7ff8000000000001 7ff8000000000001
+56
View File
@@ -551,3 +551,59 @@ fn math_drops_reserved_metadata_and_empty_labels_with_the_name() {
assert_eq!(output.metric.name(), None);
assert_eq!(names, ["__other__", "host"]);
}
/// `max_of` / `min_of` against Go's `math.Max` / `math.Min`, whose signed-zero, NaN and infinity
/// rules differ from `f64::max` / `f64::min`.
#[test]
fn max_of_and_min_of_follow_go() {
let bits = |text: &str| f64::from_bits(u64::from_str_radix(text, 16).unwrap());
let same = |a: f64, b: f64| a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan());
let mut checked = 0;
for line in include_str!("max_min_cases.txt")
.lines()
.filter(|line| !line.starts_with('#'))
{
let values: Vec<f64> = line.split_whitespace().map(bits).collect();
let (a, b) = (values[0], values[1]);
assert!(
same(ScalarFunction::MaxOf.apply(a, b), values[2]),
"max_of({a}, {b})"
);
assert!(
same(ScalarFunction::MinOf.apply(a, b), values[3]),
"min_of({a}, {b})"
);
checked += 1;
}
assert_eq!(checked, 64);
}
/// Time-varying scalars pair by absolute position; a point either one lacks has no result.
#[test]
fn scalar_functions_pair_scalars_by_position() {
let schema = Arc::new(schema(&["scalar"]));
let scalar = |start: usize, values: &[Option<f64>]| {
Series::try_new(
&schema,
MetricName::try_new(None, Vec::new()).unwrap(),
TimeAxis::Grid(
GridRange::try_new(schema.eval_grid.clone().unwrap(), start, values.len()).unwrap(),
),
SeriesFields::try_from_vec(vec![floats(values)]).unwrap(),
)
.unwrap()
};
let left = scalar(2, &[Some(1.0), Some(5.0), None, Some(-0.0)]);
let right = scalar(3, &[Some(2.0), Some(7.0), Some(0.0), Some(9.0)]);
let result = map_scalar_function(&schema, ScalarFunction::MaxOf, &left, &right)
.unwrap()
.unwrap();
assert_eq!(
run_floats(&result, 0),
vec![((3, 1), vec![Some(5.0)]), ((5, 1), vec![Some(0.0)])]
);
assert!(
run_floats(&result, 0)[1].1[0].unwrap().is_sign_positive(),
"max_of(-0, +0) is +0"
);
}
+31
View File
@@ -43,6 +43,37 @@ pub enum DateFunction {
Year,
}
/// `start()`, `end()`, `range()` and `step()`: constants of a query, which Prometheus folds into
/// number literals before evaluating it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryContextFunction {
Start,
End,
Range,
Step,
}
impl QueryContextFunction {
/// The value for a query from `start_ms` to `end_ms` every `step_ms`. `step()` is 0 when the
/// start and end are equal, as for an instant query.
pub fn value(self, start_ms: i64, end_ms: i64, step_ms: i64) -> f64 {
match self {
Self::Start => start_ms as f64 / 1000.0,
Self::End => end_ms as f64 / 1000.0,
Self::Range => duration_seconds(end_ms.saturating_sub(start_ms)),
Self::Step if start_ms == end_ms => 0.0,
Self::Step => duration_seconds(step_ms),
}
}
}
/// Go's `time.Duration.Seconds` of `ms` milliseconds: whole seconds plus the remainder over 1e9,
/// two roundings rather than one division. The nanosecond count saturates as `time.Time.Sub` does.
fn duration_seconds(ms: i64) -> f64 {
let nanos = ms.saturating_mul(1_000_000);
(nanos / 1_000_000_000) as f64 + (nanos % 1_000_000_000) as f64 / 1e9
}
/// `time()`: the evaluation time in seconds at every point of the schema's grid, unlabelled.
pub fn time(schema: &SeriesSchema) -> Result<Series> {
eval_time_series(schema, |ms| ms as f64 / 1000.0)
@@ -0,0 +1,211 @@
# Generated by promql-compat cmd/narthex-golden-scalarfn built for amd64: `ms time.Duration.Seconds()` for a millisecond difference, as hex bits.
0 0
1 3f50624dd2f1a9fc
999 3feff7ced916872b
1000 3ff0000000000000
1001 3ff004189374bc6a
4097 40106353f7ced917
86400000 40f5180000000000
9007199254740993 42012e0be826d695
9223372036854 42012e0be826d4fe
9223372036855 42012e0be826d695
2340891196754 41e170e5479820c5
28653324801615 42012e0be826d695
15150070723101 42012e0be826d695
5203274071043 41f3623a9570b021
6052846691025 41f68c71c6306666
19267443321048 42012e0be826d695
12341472526366 42012e0be826d695
20530526999188 42012e0be826d695
34395057096608 42012e0be826d695
8401369033625 41ff4c2b7c9a0000
31640814836996 42012e0be826d695
29341436692007 42012e0be826d695
34783771064630 42012e0be826d695
35122158268884 42012e0be826d695
1479560899851 41d60c13b0f676c9
23204167308761 42012e0be826d695
24788341754728 42012e0be826d695
31429837266203 42012e0be826d695
34244401504646 42012e0be826d695
13068139990387 42012e0be826d695
7761385230625 41fce9d570ea0000
9735534642756 42012e0be826d695
20240948685375 42012e0be826d695
33614386955765 42012e0be826d695
4944260193162 41f26b36c612978d
14479808172613 42012e0be826d695
17797368697448 42012e0be826d695
14261095223522 42012e0be826d695
13428055133044 42012e0be826d695
26352584022491 42012e0be826d695
12325408233432 42012e0be826d695
17010777739835 42012e0be826d695
28430422903295 42012e0be826d695
29753883088875 42012e0be826d695
25998949474507 42012e0be826d695
10226187168843 42012e0be826d695
17769199632283 42012e0be826d695
4363496320571 41f0415ab80922d1
3287173645812 41e87dc941b9fbe7
12725827928836 42012e0be826d695
24795910633675 42012e0be826d695
2828521278844 41e512f9e7db020c
4170359431716 41ef125450f6e979
3589276087583 41eabe0036f2a7f0
28524985356220 42012e0be826d695
30320656727859 42012e0be826d695
4256148642671 41efb5f5945578d5
29566978976822 42012e0be826d695
1648135601414 41d88f236c5a7efa
4780677690357 41f1cf35a3a5b646
3390646811377 41e94325436c1062
6912080800569 41f9bfdfba091aa0
19744540834685 42012e0be826d695
5048451373608 41f2ce9412d9ba5e
31511077099905 42012e0be826d695
33060246075211 42012e0be826d695
29446920826861 42012e0be826d695
20868689814545 42012e0be826d695
25732868269994 42012e0be826d695
26023816056118 42012e0be826d695
3312873794438 41e8aece284e0419
8928671130029 4200a185acd03b64
27201078145192 42012e0be826d695
28161007422010 42012e0be826d695
23548861302960 42012e0be826d695
8403898590605 41ff4e950de9ae14
2101319756957 41df4fe6133d3f7d
18153789976583 42012e0be826d695
16078846218429 42012e0be826d695
14439764018086 42012e0be826d695
15717223852073 42012e0be826d695
7594418227955 41fc4a9a033f47ae
1007547111184 41ce06f973978d50
7653748532646 41fc832ef34a5604
1542530612607 41d6fc498d26d917
8959567520889 4200b04135071cac
1963454100123 41dd41fba507df3b
15423611227189 42012e0be826d695
26921592371531 42012e0be826d695
31560677483706 42012e0be826d695
21024346859206 42012e0be826d695
21411506917513 42012e0be826d695
14299718455620 42012e0be826d695
25966354044973 42012e0be826d695
21480240207871 42012e0be826d695
13383017194856 42012e0be826d695
19694757267735 42012e0be826d695
8329763775671 41ff07e1bbfabc6a
24204352256265 42012e0be826d695
5166480575653 41f33f23cbfa72b0
17311660727623 42012e0be826d695
26768379971433 42012e0be826d695
21452255664239 42012e0be826d695
32930407231978 42012e0be826d695
15551692227912 42012e0be826d695
23123035545994 42012e0be826d695
5875644708440 41f5e37392470a3d
18292969058316 42012e0be826d695
1707825217332 41d972d610553f7d
23633727413655 42012e0be826d695
24472694155897 42012e0be826d695
32056439689975 42012e0be826d695
25133237617897 42012e0be826d695
10984619967605 42012e0be826d695
12399994332458 42012e0be826d695
17017307690557 42012e0be826d695
24405803592380 42012e0be826d695
34039839184523 42012e0be826d695
12086900862154 42012e0be826d695
4880216990563 41f22e2339e9020c
8291016285084 41fee2ede5d15810
34263874447617 42012e0be826d695
5219901505275 41f3721604146666
3325751404254 41e8c75e0d8820c5
10069265674283 42012e0be826d695
2205986208125 41e06f95b4040000
17331608106692 42012e0be826d695
13164910887275 42012e0be826d695
20165189440141 42012e0be826d695
18100958213119 42012e0be826d695
33252881487365 42012e0be826d695
28493023658516 42012e0be826d695
8853845580432 42007dd7b26374bc
34838873892001 42012e0be826d695
20214329624262 42012e0be826d695
31705600540751 42012e0be826d695
31189001046116 42012e0be826d695
29476027927210 42012e0be826d695
26781011366842 42012e0be826d695
22699855557780 42012e0be826d695
17749079048291 42012e0be826d695
30750407110207 42012e0be826d695
24417907650311 42012e0be826d695
10509573177644 42012e0be826d695
1079378703273 41d01580c3d178d5
34862833947828 42012e0be826d695
24980588397686 42012e0be826d695
11209991956240 42012e0be826d695
8937655194575 4200a5ce5cd4999a
23978885618615 42012e0be826d695
30065528340854 42012e0be826d695
2628030736846 41e39492221b126f
2663938618407 41e3d90f474d0625
34895264552379 42012e0be826d695
15670995231869 42012e0be826d695
4191047303785 41ef39c9d0f91eb8
8437263482680 41ff6e66c7aae148
3960463773594 41ed81fc33b3020c
4309293785322 41f00da9ad9526e9
26649392599963 42012e0be826d695
4157860955829 41eefa7d8b7a872b
22296599249089 42012e0be826d695
32695131508279 42012e0be826d695
30635397919632 42012e0be826d695
15331538117365 42012e0be826d695
11063348740606 42012e0be826d695
32371941731295 42012e0be826d695
13719859399284 42012e0be826d695
7293417131828 41fb2b8b6abd3f7d
33407534042909 42012e0be826d695
31297857606586 42012e0be826d695
1097102363540 41d0591d06e28f5c
25332039849587 42012e0be826d695
20041642732253 42012e0be826d695
18752081044817 42012e0be826d695
29442119239956 42012e0be826d695
28855454932055 42012e0be826d695
31958604071351 42012e0be826d695
18688407702704 42012e0be826d695
17871169582207 42012e0be826d695
16003600945447 42012e0be826d695
5284509633417 41f3afb37c16ac08
11575618223404 42012e0be826d695
23263846003261 42012e0be826d695
14260376149051 42012e0be826d695
20109100243330 42012e0be826d695
16359587412236 42012e0be826d695
34836697130263 42012e0be826d695
23618221322728 42012e0be826d695
16214535784284 42012e0be826d695
8272436198602 41fed135be69a1cb
5401941921460 41f41fb17a175c29
7235498080071 41faf44f060122d1
14773466706669 42012e0be826d695
34843449378732 42012e0be826d695
23707829047074 42012e0be826d695
4081628494438 41ee6916a9ce0419
13573087199008 42012e0be826d695
21542290482225 42012e0be826d695
16883004474069 42012e0be826d695
16172951872856 42012e0be826d695
5849767460850 41f5cac5e24d999a
12771076879764 42012e0be826d695
7080148168638 41fa6027cc8a353f
13469889734230 42012e0be826d695
9296342191848 42012e0be826d695
753418709293 41c67420eaa58106
23411833150514 42012e0be826d695
29448477422232 42012e0be826d695
29413433948272 42012e0be826d695
+36 -1
View File
@@ -24,7 +24,7 @@ use datatypes::series::samples::{
};
use datatypes::series::time::{EvalGrid, GridRange, GridSpan, GridSpec, TimeAxis};
use super::{DateFunction, date_of_eval_time, map_date, time};
use super::{DateFunction, QueryContextFunction, date_of_eval_time, map_date, time};
use crate::grid_series::merge_same_labelset;
use crate::grid_series::reference::{
Labels, Points, canonical_bits, golden_cases, labels_of, metric_of, points_by_labels,
@@ -289,3 +289,38 @@ fn map_date_reads_a_sparse_axis_by_its_positions() {
])
);
}
/// `range()` and `step()` are Go `Duration.Seconds` of a millisecond count, which rounds twice
/// and so differs from `ms / 1000` in the last bit for most values.
#[test]
fn query_context_durations_match_go() {
let mut checked = 0;
for line in include_str!("duration_seconds_cases.txt")
.lines()
.filter(|line| !line.starts_with('#'))
{
let (ms, bits) = line.split_once(' ').unwrap();
let ms: i64 = ms.parse().unwrap();
let expected = u64::from_str_radix(bits, 16).unwrap();
assert_eq!(
QueryContextFunction::Range.value(0, ms, 1).to_bits(),
expected,
"range() over {ms} ms"
);
assert_eq!(
QueryContextFunction::Step.value(0, 1, ms).to_bits(),
expected,
"step() of {ms} ms"
);
checked += 1;
}
assert!(checked > 200, "{checked} durations checked");
// Two valid endpoints whose difference leaves `i64` saturate, as `time.Time.Sub` does.
assert_eq!(
QueryContextFunction::Range.value(i64::MIN / 2, i64::MAX / 2 + 1, 1),
QueryContextFunction::Range.value(0, i64::MAX, 1)
);
assert_eq!(QueryContextFunction::Step.value(5_000, 5_000, 1_000), 0.0);
assert_eq!(QueryContextFunction::Start.value(-1_500, 0, 1), -1.5);
assert_eq!(QueryContextFunction::End.value(0, 2_250, 1), 2.25);
}
+17 -3
View File
@@ -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!(
+18 -2
View File
@@ -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 [...]`.
+1
View File
@@ -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: {
+23 -15
View File
@@ -122,8 +122,8 @@ pub struct AggregateStateSchema {
/// A scan or exchange boundary bound as a node input.
///
/// The binding records what the input was created for: the output a consumer type-checks against,
/// and the ordering it guarantees. A window reads the ordering, rejecting an interleaved input; no
/// other consumer reads it yet.
/// and the ordering it guarantees. An instant selection and a window both read the ordering,
/// rejecting an interleaved input; no other consumer reads it yet.
#[derive(Clone, Debug)]
pub struct BoundInput {
/// What the input produces: raw samples from a scan, or state from an exchange boundary.
@@ -329,14 +329,13 @@ impl NativeNode {
)));
}
}
ensure_not_interleaved(input)?;
ensure_samples(&self.output, "an instant selection")?;
// A selection is one source row per grid point: `select_instants` picks row
// indices and every field is taken at them, so it carries one value per input
// field.
ensure_same_fields(&input.output, &self.output, "an instant selection")?;
ensure_grid(&self.output, &op.output_grid, "an instant selection")?;
// `select_instants` reads one series at a time and holds no cross-series state, so
// an interleaved input is enough for it: no ordering check here.
}
NativeExpr::Window { input, op } => {
input.validate()?;
@@ -354,17 +353,7 @@ impl NativeNode {
"a window reads an empty range: its range must be positive",
));
}
// A range evaluator consumes one series to its last chunk before it starts the
// next, so interleaved chunks of different series cannot be fed to it. Only an
// input node carries an ordering, so only there is there one to check.
if let NativeExpr::Input(binding) = &input.expr
&& binding.ordering == SeriesOrdering::Interleaved
{
return Err(invalid(
"a window reads one series to its last chunk before the next, so \
interleaved chunks cannot be fed to it",
));
}
ensure_not_interleaved(input)?;
// What is checked here and just below is that the declared input grid equals the
// input's grid, and that the output grid matches the declared one. The grid is
// not checked to cover the window's read range
@@ -624,6 +613,25 @@ fn ensure_grid(ty: &NativeType, expected: &Arc<EvalGrid>, what: &str) -> Result<
}
}
/// Checks that `input`, when it is a [`NativeExpr::Input`], does not declare interleaved series.
///
/// A native evaluation reads one series to its last chunk before the next: a range evaluator folds
/// a series completely before it starts another, and an instant selection's consumer collects one
/// series' chunks into one buffer. Neither can be handed chunks of different series that
/// interleave, which is the one rule both consumers state. Only an input node carries an ordering,
/// so only there is there one to check.
fn ensure_not_interleaved(input: &NativeNode) -> Result<()> {
if let NativeExpr::Input(binding) = &input.expr
&& binding.ordering == SeriesOrdering::Interleaved
{
return Err(invalid(
"a native evaluation reads one series to its last chunk before the next, so \
interleaved chunks cannot be fed to it",
));
}
Ok(())
}
fn invalid(reason: &str) -> DataFusionError {
// A rejected tree is a compiler defect, not a query error: the compiler only builds a node
// after deciding that this combination is supported. `Internal` keeps that meaning on the wire
+42 -9
View File
@@ -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()
+14 -10
View File
@@ -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.
+5
View File
@@ -73,6 +73,11 @@ impl RangeFunction {
/// the same as saying `IS_COUNTER` in `extrapolated_rate`: `rate` and `increase`. A kernel
/// that corrects counters without the index being maintained would silently drop every
/// correction, so a new counter function has to be added here as well.
///
/// It governs both readers of the index: the per-window kernel above, and the batch kernel,
/// which reads [`FloatWindows::resets_of`]. When it returns `false`, `resets` stays empty and
/// `resets_of` yields nothing, so a batch kernel for a counter function would skip every
/// correction and report `delta`-like values.
pub(crate) fn indexes_counter_resets(self) -> bool {
matches!(self, Self::Rate | Self::Increase)
}
+182 -28
View File
@@ -721,6 +721,185 @@ fn functions_match_range_udfs() {
}
}
/// Replaces the float sample of `field` at every row index in `rows` with a native histogram
/// sample, so the windows holding one take the mixed-window path.
fn mix_in_histograms(chunks: &mut [RawSeriesChunk], field: usize, rows: &BTreeSet<usize>) {
let histogram = "0/0/0/4014000000000000/4014000000000000/1/0_1/4014000000000000///";
let view = OwnedHistogram::parse(histogram);
let mut start = 0;
for piece in chunks {
let len = piece.time.len();
let local: Vec<usize> = rows
.range(start..start + len)
.map(|&row| row - start)
.collect();
start += len;
if local.is_empty() {
continue;
}
let mut builder = HistogramBatchBuilder::new();
let indices: UInt32Array = (0..len)
.map(|row| {
local
.binary_search(&row)
.is_ok()
.then(|| builder.push(view.view()).unwrap())
})
.collect();
// A position is a float or a histogram, never both, so the float sample of a mixed-in row
// goes.
let values: Float64Array = piece.fields.as_slice()[field]
.iter()
.enumerate()
.map(|(row, sample)| match sample {
_ if local.binary_search(&row).is_ok() => None,
SampleRef::Float(value) => Some(value),
SampleRef::Missing => None,
SampleRef::Histogram(_) => unreachable!("histograms are mixed in once"),
})
.collect();
let histograms = HistogramSamples::try_new(indices, builder.finish().unwrap()).unwrap();
let mixed = Samples::try_from_mixed(values, histograms).unwrap();
let fields = piece
.fields
.as_slice()
.iter()
.enumerate()
.map(|(index, samples)| {
if index == field {
mixed.clone()
} else {
samples.clone()
}
})
.collect();
piece.fields = SeriesFields::try_from_vec(fields).unwrap();
}
}
/// Asserts two evaluator results are identical, including native histogram samples, which the
/// dense form [`expand`] rejects.
fn assert_same_series(actual: Option<&Series>, expected: Option<&Series>, context: &str) {
match (actual, expected) {
(None, None) => {}
(Some(actual), Some(expected)) => {
assert_eq!(actual.metric, expected.metric, "{context}: metric");
let (TimeAxis::Grid(actual_range), TimeAxis::Grid(expected_range)) =
(&actual.time, &expected.time)
else {
panic!(
"{context}: expected grid series, got {:?} and {:?}",
actual.time, expected.time
);
};
assert_eq!(actual_range, expected_range, "{context}: grid range");
for (field, (got, want)) in actual
.fields
.as_slice()
.iter()
.zip(expected.fields.as_slice())
.enumerate()
{
for (row, (got, want)) in got.iter().zip(want.iter()).enumerate() {
let context = format!("{context}: field {field} row {row}");
match (got, want) {
(SampleRef::Missing, SampleRef::Missing) => {}
(SampleRef::Float(got), SampleRef::Float(want)) => {
assert_eq!(got.to_bits(), want.to_bits(), "{context}: {got} vs {want}")
}
(SampleRef::Histogram(got), SampleRef::Histogram(want)) => {
let got = FloatHistogram::try_from_view(&got).unwrap();
let want = FloatHistogram::try_from_view(&want).unwrap();
// Both paths run the same histogram code over the same samples, so
// this is an exact comparison: an epsilon of zero.
assert_histogram_close(&got, &want, 0.0, &context);
}
(SampleRef::Missing, _) => {
panic!("{context}: only the per-window path produced a sample")
}
(_, SampleRef::Missing) => {
panic!("{context}: only the batch path produced a sample")
}
_ => panic!("{context}: a float on one path and a histogram on the other"),
}
}
}
}
(actual, expected) => panic!(
"{context}: one path produced a series, the other none: {actual:?} vs {expected:?}"
),
}
}
/// `rate` and `increase` over random input, computed twice: by the batch kernel and by the
/// per-window path.
///
/// Both evaluators are built by `try_new` and differ in nothing but the dispatch shape, and both
/// kernels reach `extrapolated_rate_of`, so the results have to be bit-identical. A difference
/// means the batch read a different set of samples, evaluated at a different time, or corrected a
/// different reset: a window bound that disagrees with the per-window path.
///
/// The rows mix gaps, stale markers, counter resets and native histogram samples, the last so
/// that some grid points take the mixed-window fallback, where a pending batch has to flush
/// before the point is decided on the per-window path. The random chunk splits are what make
/// `advance` drop samples mid-series, so spans that outlived one would read shifted samples.
#[test]
fn batch_counter_functions_match_the_per_window_path_on_random_input() {
let mut batch_calls = 0;
for seed in 0..200 {
let mut rng = StdRng::seed_from_u64(seed);
let fields = rng.random_range(1..=3);
let (grid, mut window) = random_window(&mut rng);
// An anchored selector reads one window and installs no batch kernel, so both paths would
// run the same code; every seed stays on the grid-driven batch path.
window.anchor_ms = None;
let function = if rng.random_bool(0.5) {
RangeFunction::Rate
} else {
RangeFunction::Increase
};
let mut batched =
RangeEvaluator::try_new(&raw_schema(fields), grid.clone(), window, function).unwrap();
let mut per_window =
RangeEvaluator::try_new(&raw_schema(fields), grid.clone(), window, function).unwrap();
// The per-window path is the same evaluator without the batch kernel. `with_kernel`,
// which the other differential tests use, rejects native histogram samples, and these
// series carry some, so removing the batch form is the only switch that leaves the rest
// of the evaluation identical.
per_window.batch = None;
// One evaluator serves several series, so state must not leak between them.
for host in 0..3 {
let rows = random_rows(&mut rng, fields);
let histogram_field = rng.random_range(0..fields);
let histogram_rows: BTreeSet<usize> =
(0..rows.len()).filter(|_| rng.random_bool(0.1)).collect();
let mut chunks = random_chunks(&mut rng, &rows, fields, host);
mix_in_histograms(&mut chunks, histogram_field, &histogram_rows);
// A push hands out the positions it decided, not only on the last chunk, so what each
// path produced is every chunk it handed out, folded back into one series.
let batched_chunks = collect_chunks(&mut batched, &chunks);
let per_window_chunks = collect_chunks(&mut per_window, &chunks);
let batched_result = fold_chunks(&batched_chunks, batched.output_schema());
let per_window_result = fold_chunks(&per_window_chunks, per_window.output_schema());
let context = format!("seed {seed} host {host}");
assert_same_series(
batched_result.as_ref(),
per_window_result.as_ref(),
&context,
);
}
batch_calls += batched.batch_call_count();
}
// The comparison is worth nothing unless the batch kernel ran at all: counting the calls is
// what tells a batch-path comparison from one that silently fell back to per-window.
assert!(
batch_calls > 300,
"only {batch_calls} batch kernel calls: the batch path was barely reached"
);
}
/// A chunk of entirely present float columns is appended in bulk, and that is the path the test
/// asserts: the per-sample loop produces the same output, so comparing values alone could not
/// tell whether the bulk path ran.
@@ -786,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
View File
@@ -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
View File
@@ -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.
+62
View File
@@ -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
View File
@@ -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
+94 -6
View File
@@ -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,
+9 -8
View File
@@ -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
+44 -29
View File
@@ -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");
}
-40
View File
@@ -29,11 +29,9 @@ use common_function::handlers::{
FlowServiceHandlerRef, ProcedureServiceHandlerRef, TableMutationHandlerRef,
};
use common_query::Output;
use common_query::request::{PreparedNamedRawSeries, RawSeriesRequest};
use datafusion::catalog::TableFunction;
use datafusion::dataframe::DataFrame;
use datafusion_expr::{AggregateUDF, LogicalPlan, WindowUDF};
use datatypes::series::SeriesOrdering;
pub use default_serializer::{DefaultPlanDecoder, DefaultSerializer};
use partition::manager::PartitionRuleManagerRef;
use session::context::QueryContextRef;
@@ -79,44 +77,6 @@ pub trait QueryEngine: Send + Sync {
/// Execute the given [`LogicalPlan`].
async fn execute(&self, plan: LogicalPlan, query_ctx: QueryContextRef) -> Result<Output>;
/// Non-consumingly checks whether a native single-region raw-series scan is supported.
/// Returns its ordering on support, `None` when explicitly unsupported, and propagates errors.
/// This point-in-time check does not reserve a plan; execution revalidates support.
async fn prepare_raw_series(
&self,
_plan: LogicalPlan,
_query_ctx: QueryContextRef,
_binding: RawSeriesRequest,
) -> Result<Option<SeriesOrdering>> {
Ok(None)
}
/// Non-consumingly checks whether a named raw-series preflight is supported.
///
/// Returns the approved binding and ordering on support, or `None` when the plan is
/// explicitly unsupported. Errors propagate unchanged. This point-in-time check does not
/// reserve a plan; execution revalidates support.
async fn prepare_named_raw_series(
&self,
_plan: LogicalPlan,
_query_ctx: QueryContextRef,
) -> Result<Option<PreparedNamedRawSeries>> {
Ok(None)
}
/// Execute a native single-region raw-series scan.
async fn execute_raw_series(
&self,
_plan: LogicalPlan,
_query_ctx: QueryContextRef,
_binding: RawSeriesRequest,
) -> Result<Output> {
crate::error::UnimplementedSnafu {
operation: "native raw-series query",
}
.fail()
}
/// Register an aggregate function.
///
/// # Panics
+1 -15
View File
@@ -18,7 +18,7 @@ use api::v1::region::{RemoteDynFilterUnregister, RemoteDynFilterUpdate};
use async_trait::async_trait;
use common_meta::node_manager::NodeManagerRef;
use common_meta::peer::Peer;
use common_query::request::{PreparedNamedRawSeries, QueryRequest};
use common_query::request::QueryRequest;
use common_recordbatch::SendableRecordBatchStream;
use partition::manager::PartitionRuleManagerRef;
use session::ReadPreference;
@@ -68,20 +68,6 @@ pub trait RegionQueryHandler: Send + Sync {
request: QueryRequest,
) -> Result<SendableRecordBatchStream>;
/// Non-consumingly checks whether a named raw-series preflight is supported.
///
/// Returns the approved binding and ordering on support, or `None` when the request is
/// explicitly unsupported; failures that are not unsupported preparation are returned as
/// errors. This point-in-time check does not reserve a plan, and execution revalidates
/// support.
async fn prepare_named_raw_series_query(
&self,
_target: &RegionQueryTarget,
_request: QueryRequest,
) -> Result<Option<PreparedNamedRawSeries>> {
Ok(None)
}
async fn handle_remote_dyn_filter_update(
&self,
target: &RegionQueryTarget,
@@ -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}