mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 06:46:56 +00:00
2714 lines
91 KiB
Rust
2714 lines
91 KiB
Rust
// 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.
|
|
|
|
//! Shared native histogram field contract.
|
|
//!
|
|
//! Prom remote-write v2 stores these names as children of one Struct field, while
|
|
//! metric-engine uses the same contract to recognize native histogram tables.
|
|
//! [`NativeHistogram`] is the query-time representation and therefore normalizes
|
|
//! integer and floating-point payloads to absolute `f64` counts.
|
|
|
|
mod encoding;
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use datafusion::arrow::array::{
|
|
Array, ArrayRef, Float64Array, Int32Array, Int64Array, ListArray, PrimitiveArray, StructArray,
|
|
TimestampMillisecondArray,
|
|
};
|
|
use datafusion::arrow::buffer::NullBuffer;
|
|
use datafusion::arrow::datatypes::{
|
|
ArrowPrimitiveType, DataType as ArrowDataType, Field, Float64Type, Int32Type, Int64Type,
|
|
TimestampMillisecondType,
|
|
};
|
|
use datafusion_common::{DataFusionError, Result as DfResult};
|
|
use datatypes::data_type::{ConcreteDataType, DataType};
|
|
use datatypes::series::histogram::{BucketSideView, HistogramBatchBuilder, HistogramView};
|
|
use datatypes::types::{StructField, StructType};
|
|
pub use encoding::{NativeHistogramError, encode_native_histogram, native_histogram_column_schema};
|
|
use once_cell::sync::Lazy;
|
|
|
|
use crate::prelude::greptime_native_histogram;
|
|
use crate::prometheus::format_prometheus_float;
|
|
|
|
pub const NATIVE_HISTOGRAM_FIELD: &str = "greptime_native_histogram";
|
|
pub const SCHEMA_FIELD: &str = "schema";
|
|
pub const ZERO_THRESHOLD_FIELD: &str = "zero_threshold";
|
|
pub const SUM_FIELD: &str = "sum";
|
|
pub const RESET_HINT_FIELD: &str = "reset_hint";
|
|
pub const START_TIMESTAMP_FIELD: &str = "start_timestamp";
|
|
pub const CUSTOM_VALUES_FIELD: &str = "custom_values";
|
|
pub const POSITIVE_SPAN_OFFSETS_FIELD: &str = "positive_span_offsets";
|
|
pub const POSITIVE_SPAN_LENGTHS_FIELD: &str = "positive_span_lengths";
|
|
pub const NEGATIVE_SPAN_OFFSETS_FIELD: &str = "negative_span_offsets";
|
|
pub const NEGATIVE_SPAN_LENGTHS_FIELD: &str = "negative_span_lengths";
|
|
pub const COUNT_I64_FIELD: &str = "count_i64";
|
|
pub const ZERO_COUNT_I64_FIELD: &str = "zero_count_i64";
|
|
pub const POSITIVE_BUCKETS_I64_FIELD: &str = "positive_buckets_i64";
|
|
pub const NEGATIVE_BUCKETS_I64_FIELD: &str = "negative_buckets_i64";
|
|
pub const COUNT_F64_FIELD: &str = "count_f64";
|
|
pub const ZERO_COUNT_F64_FIELD: &str = "zero_count_f64";
|
|
pub const POSITIVE_BUCKETS_F64_FIELD: &str = "positive_buckets_f64";
|
|
pub const NEGATIVE_BUCKETS_F64_FIELD: &str = "negative_buckets_f64";
|
|
|
|
// Keep int and float payloads in separate columns. The populated family is the
|
|
// type signal, so we don't need to persist an extra histogram-type tag.
|
|
pub const NATIVE_HISTOGRAM_FIELD_NAMES: &[&str] = &[
|
|
SCHEMA_FIELD,
|
|
ZERO_THRESHOLD_FIELD,
|
|
SUM_FIELD,
|
|
RESET_HINT_FIELD,
|
|
START_TIMESTAMP_FIELD,
|
|
CUSTOM_VALUES_FIELD,
|
|
POSITIVE_SPAN_OFFSETS_FIELD,
|
|
POSITIVE_SPAN_LENGTHS_FIELD,
|
|
NEGATIVE_SPAN_OFFSETS_FIELD,
|
|
NEGATIVE_SPAN_LENGTHS_FIELD,
|
|
COUNT_I64_FIELD,
|
|
ZERO_COUNT_I64_FIELD,
|
|
POSITIVE_BUCKETS_I64_FIELD,
|
|
NEGATIVE_BUCKETS_I64_FIELD,
|
|
COUNT_F64_FIELD,
|
|
ZERO_COUNT_F64_FIELD,
|
|
POSITIVE_BUCKETS_F64_FIELD,
|
|
NEGATIVE_BUCKETS_F64_FIELD,
|
|
];
|
|
|
|
static NATIVE_HISTOGRAM_VALUE_TYPE: Lazy<ConcreteDataType> = Lazy::new(|| {
|
|
let fields = NATIVE_HISTOGRAM_FIELD_NAMES
|
|
.iter()
|
|
.filter_map(|name| {
|
|
let data_type = native_histogram_field_type(name)?;
|
|
Some(StructField::new((*name).to_string(), data_type, true))
|
|
})
|
|
.collect();
|
|
ConcreteDataType::struct_datatype(StructType::new(Arc::new(fields)))
|
|
});
|
|
|
|
/// Returns the exact Greptime type for a persisted native histogram field.
|
|
pub fn native_histogram_field_type(name: &str) -> Option<ConcreteDataType> {
|
|
match name {
|
|
SCHEMA_FIELD | RESET_HINT_FIELD => Some(ConcreteDataType::int32_datatype()),
|
|
ZERO_THRESHOLD_FIELD | SUM_FIELD | COUNT_F64_FIELD | ZERO_COUNT_F64_FIELD => {
|
|
Some(ConcreteDataType::float64_datatype())
|
|
}
|
|
START_TIMESTAMP_FIELD => Some(ConcreteDataType::timestamp_millisecond_datatype()),
|
|
CUSTOM_VALUES_FIELD | POSITIVE_BUCKETS_F64_FIELD | NEGATIVE_BUCKETS_F64_FIELD => Some(
|
|
ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::float64_datatype())),
|
|
),
|
|
POSITIVE_SPAN_OFFSETS_FIELD | NEGATIVE_SPAN_OFFSETS_FIELD => Some(
|
|
ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::int32_datatype())),
|
|
),
|
|
POSITIVE_SPAN_LENGTHS_FIELD | NEGATIVE_SPAN_LENGTHS_FIELD => Some(
|
|
ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::int32_datatype())),
|
|
),
|
|
COUNT_I64_FIELD | ZERO_COUNT_I64_FIELD => Some(ConcreteDataType::int64_datatype()),
|
|
POSITIVE_BUCKETS_I64_FIELD | NEGATIVE_BUCKETS_I64_FIELD => Some(
|
|
ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::int64_datatype())),
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Returns the exact Greptime type for a complete native histogram value.
|
|
pub fn native_histogram_value_type() -> &'static ConcreteDataType {
|
|
&NATIVE_HISTOGRAM_VALUE_TYPE
|
|
}
|
|
|
|
/// Returns whether `data_type` matches the native histogram value contract.
|
|
pub fn is_native_histogram_value_type(data_type: &ConcreteDataType) -> bool {
|
|
data_type == native_histogram_value_type()
|
|
}
|
|
|
|
/// Returns whether a named column matches the configured native histogram contract.
|
|
pub fn is_native_histogram_value_schema(name: &str, data_type: &ConcreteDataType) -> bool {
|
|
name == greptime_native_histogram() && is_native_histogram_value_type(data_type)
|
|
}
|
|
|
|
/// Schema identifier for native histograms with explicit custom bucket bounds.
|
|
pub const CUSTOM_BUCKETS_SCHEMA: i32 = -53;
|
|
const MIN_EXPONENTIAL_SCHEMA: i32 = -4;
|
|
const MAX_EXPONENTIAL_SCHEMA: i32 = 8;
|
|
|
|
pub use datatypes::series::histogram::CounterResetHint;
|
|
|
|
/// Reset hint indicating that the payload must be inspected.
|
|
pub const UNKNOWN_COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::Unknown;
|
|
/// Reset hint indicating the first histogram after a counter reset.
|
|
pub const COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::CounterReset;
|
|
/// Reset hint indicating that no counter reset occurred.
|
|
pub const NOT_COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::NotCounterReset;
|
|
/// Reset hint identifying a gauge histogram.
|
|
pub const GAUGE_RESET_HINT: CounterResetHint = CounterResetHint::Gauge;
|
|
|
|
/// A contiguous run of populated sparse buckets.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Span {
|
|
/// The first bucket index, or the gap after the preceding span.
|
|
pub offset: i32,
|
|
/// Number of consecutive buckets in the span.
|
|
pub length: i32,
|
|
}
|
|
|
|
/// Inclusion rules for a materialized bucket's lower and upper bounds.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum BoundaryRule {
|
|
OpenLeft = 0,
|
|
OpenRight = 1,
|
|
ClosedBoth = 3,
|
|
}
|
|
|
|
/// A materialized bucket used by query-time estimators and renderers.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct Bucket {
|
|
lower: f64,
|
|
upper: f64,
|
|
count: f64,
|
|
boundary_rule: BoundaryRule,
|
|
}
|
|
|
|
/// Additional information produced while estimating a native histogram quantile.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum NativeHistogramQuantileInfo {
|
|
/// NaN observations made the estimated quantile skew higher.
|
|
NaNSkew,
|
|
/// The requested quantile fell beyond all populated buckets because of NaN observations.
|
|
NaNResult,
|
|
}
|
|
|
|
/// Query-time representation of a Prometheus native histogram.
|
|
///
|
|
/// Bucket counts are absolute `f64` values even when the persisted payload used
|
|
/// integer counts or deltas.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct NativeHistogram {
|
|
/// Exponential bucket schema, or [`CUSTOM_BUCKETS_SCHEMA`].
|
|
pub schema: i32,
|
|
/// Absolute width of the zero bucket.
|
|
pub zero_threshold: f64,
|
|
/// Sum of all observations.
|
|
pub sum: f64,
|
|
/// Counter reset information associated with this sample.
|
|
pub reset_hint: CounterResetHint,
|
|
/// Optional time in milliseconds when this histogram started counting.
|
|
pub start_timestamp: Option<i64>,
|
|
/// Inclusive upper bounds used when `schema` is [`CUSTOM_BUCKETS_SCHEMA`].
|
|
pub custom_values: Vec<f64>,
|
|
/// Sparse spans describing positive or custom buckets.
|
|
pub positive_spans: Vec<Span>,
|
|
/// Sparse spans describing negative buckets.
|
|
pub negative_spans: Vec<Span>,
|
|
/// Total number of observations.
|
|
pub count: f64,
|
|
/// Number of observations in the zero bucket.
|
|
pub zero_count: f64,
|
|
/// Absolute counts for positive or custom buckets.
|
|
pub positive_buckets: Vec<f64>,
|
|
/// Absolute counts for negative buckets.
|
|
pub negative_buckets: Vec<f64>,
|
|
}
|
|
|
|
/// Formats a histogram component like Prometheus's default `%g` formatter.
|
|
fn format_promql_histogram_float(value: f64) -> String {
|
|
let abs = value.abs();
|
|
if !value.is_finite() || value == 0.0 || (1e-4..1e6).contains(&abs) {
|
|
return format_prometheus_float(value);
|
|
}
|
|
|
|
let scientific = format!("{value:e}");
|
|
let Some((mantissa, exponent)) = scientific.rsplit_once('e') else {
|
|
return scientific;
|
|
};
|
|
let (sign, exponent) = if let Some(exponent) = exponent.strip_prefix('-') {
|
|
('-', exponent)
|
|
} else {
|
|
('+', exponent.strip_prefix('+').unwrap_or(exponent))
|
|
};
|
|
format!("{mantissa}e{sign}{exponent:0>2}")
|
|
}
|
|
|
|
impl NativeHistogram {
|
|
/// Appends this histogram, borrowing its bucket and custom-bound slices as input.
|
|
/// Span metadata is staged into temporary column vectors; the builder copies all input
|
|
/// values into its owned result storage.
|
|
pub fn push_to(&self, builder: &mut HistogramBatchBuilder) -> datatypes::Result<u32> {
|
|
let positive_offsets: Vec<_> = self.positive_spans.iter().map(|span| span.offset).collect();
|
|
let positive_lengths: Vec<_> = self.positive_spans.iter().map(|span| span.length).collect();
|
|
let negative_offsets: Vec<_> = self.negative_spans.iter().map(|span| span.offset).collect();
|
|
let negative_lengths: Vec<_> = self.negative_spans.iter().map(|span| span.length).collect();
|
|
builder.push(HistogramView {
|
|
schema: self.schema,
|
|
zero_threshold: self.zero_threshold,
|
|
sum: self.sum,
|
|
count: self.count,
|
|
zero_count: self.zero_count,
|
|
reset_hint: self.reset_hint,
|
|
start_timestamp: self.start_timestamp,
|
|
custom_values: &self.custom_values,
|
|
positive: BucketSideView {
|
|
span_offsets: &positive_offsets,
|
|
span_lengths: &positive_lengths,
|
|
buckets: &self.positive_buckets,
|
|
},
|
|
negative: BucketSideView {
|
|
span_offsets: &negative_offsets,
|
|
span_lengths: &negative_lengths,
|
|
buckets: &self.negative_buckets,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
impl NativeHistogram {
|
|
fn uses_custom_buckets(&self) -> bool {
|
|
self.schema == CUSTOM_BUCKETS_SCHEMA
|
|
}
|
|
|
|
fn compatible_with(&self, other: &Self) -> bool {
|
|
self.schema == other.schema
|
|
&& self.zero_threshold == other.zero_threshold
|
|
&& self.custom_values == other.custom_values
|
|
}
|
|
|
|
/// Returns a histogram with the same layout and metadata but zeroed payload values.
|
|
pub fn zero_like(&self) -> Self {
|
|
let mut result = self.clone();
|
|
result.count = 0.0;
|
|
result.zero_count = 0.0;
|
|
result.sum = 0.0;
|
|
result.positive_buckets.fill(0.0);
|
|
result.negative_buckets.fill(0.0);
|
|
result
|
|
}
|
|
|
|
fn combine_exact(
|
|
&self,
|
|
other: &Self,
|
|
reset_hint: CounterResetHint,
|
|
op: impl Fn(f64, f64) -> f64 + Copy,
|
|
) -> Option<Self> {
|
|
if !self.compatible_with(other) {
|
|
return None;
|
|
}
|
|
|
|
let mut result = self.clone();
|
|
result.count = op(result.count, other.count);
|
|
result.zero_count = op(result.zero_count, other.zero_count);
|
|
result.sum = op(result.sum, other.sum);
|
|
result.reset_hint = reset_hint;
|
|
(result.positive_spans, result.positive_buckets) = merge_side(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
&other.positive_spans,
|
|
&other.positive_buckets,
|
|
op,
|
|
)?;
|
|
(result.negative_spans, result.negative_buckets) = merge_side(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
&other.negative_spans,
|
|
&other.negative_buckets,
|
|
op,
|
|
)?;
|
|
Some(result)
|
|
}
|
|
|
|
/// Adds two histograms after reconciling compatible bucket layouts.
|
|
///
|
|
/// Returns `None` when the layouts are invalid or cannot be reconciled.
|
|
pub fn add(&self, other: &Self) -> Option<Self> {
|
|
let reset_hint = add_reset_hint(self.reset_hint, other.reset_hint);
|
|
let (left, right) = self.reconcile(other)?;
|
|
left.combine_exact(&right, reset_hint, |left, right| left + right)?
|
|
.compact()
|
|
}
|
|
|
|
/// Subtracts `other` after reconciling compatible bucket layouts.
|
|
///
|
|
/// Returns `None` when the layouts are invalid or cannot be reconciled.
|
|
pub fn sub(&self, other: &Self) -> Option<Self> {
|
|
let (left, right) = self.reconcile(other)?;
|
|
left.combine_exact(&right, CounterResetHint::Gauge, |left, right| left - right)?
|
|
.compact()
|
|
}
|
|
|
|
/// Negates every payload value and marks the result as a gauge histogram.
|
|
pub fn negated(self) -> Self {
|
|
self.scale(-1.0)
|
|
}
|
|
|
|
/// Marks this histogram as a gauge without changing its payload.
|
|
pub fn into_gauge(mut self) -> Self {
|
|
self.reset_hint = CounterResetHint::Gauge;
|
|
self
|
|
}
|
|
|
|
/// Returns whether the histograms carry explicit, contradictory reset hints.
|
|
pub fn counter_reset_hints_contradict(&self, other: &Self) -> bool {
|
|
matches!(
|
|
(self.reset_hint, other.reset_hint),
|
|
(
|
|
CounterResetHint::CounterReset,
|
|
CounterResetHint::NotCounterReset
|
|
) | (
|
|
CounterResetHint::NotCounterReset,
|
|
CounterResetHint::CounterReset
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Returns whether two custom histograms use different bucket bounds.
|
|
pub fn needs_custom_reconciliation(&self, other: &Self) -> bool {
|
|
self.uses_custom_buckets()
|
|
&& other.uses_custom_buckets()
|
|
&& self.custom_values != other.custom_values
|
|
}
|
|
|
|
/// Compares PromQL-visible payload values by their exact bit patterns.
|
|
///
|
|
/// Reset hints and start timestamps are ignored.
|
|
pub fn promql_eq(&self, other: &Self) -> bool {
|
|
self.schema == other.schema
|
|
&& self.zero_threshold == other.zero_threshold
|
|
&& self.custom_values == other.custom_values
|
|
&& self.count.to_bits() == other.count.to_bits()
|
|
&& self.zero_count.to_bits() == other.zero_count.to_bits()
|
|
&& self.sum.to_bits() == other.sum.to_bits()
|
|
&& side_layout_equal(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
&other.positive_spans,
|
|
&other.positive_buckets,
|
|
)
|
|
&& side_layout_equal(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
&other.negative_spans,
|
|
&other.negative_buckets,
|
|
)
|
|
}
|
|
|
|
/// Formats this histogram using PromQL native histogram sample notation.
|
|
pub fn promql_string(&self) -> String {
|
|
let mut parts = vec![
|
|
format!("count:{}", format_promql_histogram_float(self.count)),
|
|
format!("sum:{}", format_promql_histogram_float(self.sum)),
|
|
];
|
|
if let Some(buckets) = self.all_buckets() {
|
|
parts.extend(
|
|
buckets
|
|
.into_iter()
|
|
.filter(|bucket| bucket.count != 0.0)
|
|
.map(|bucket| {
|
|
let (left, right) = match bucket.boundary_rule {
|
|
BoundaryRule::OpenLeft => ("(", "]"),
|
|
BoundaryRule::OpenRight => ("[", ")"),
|
|
BoundaryRule::ClosedBoth => ("[", "]"),
|
|
};
|
|
format!(
|
|
"{}{},{}{}:{}",
|
|
left,
|
|
format_promql_histogram_float(bucket.lower),
|
|
format_promql_histogram_float(bucket.upper),
|
|
right,
|
|
format_promql_histogram_float(bucket.count)
|
|
)
|
|
}),
|
|
);
|
|
}
|
|
format!("{{{}}}", parts.join(", "))
|
|
}
|
|
|
|
/// Estimates the population variance using each populated bucket's midpoint.
|
|
pub fn estimated_stdvar(&self) -> f64 {
|
|
if self.count == 0.0 {
|
|
return f64::NAN;
|
|
}
|
|
let mean = self.sum / self.count;
|
|
let Some(buckets) = self.all_buckets() else {
|
|
return f64::NAN;
|
|
};
|
|
buckets
|
|
.into_iter()
|
|
.map(|bucket| {
|
|
let midpoint = self.bucket_midpoint(&bucket);
|
|
bucket.count * (midpoint - mean).powi(2)
|
|
})
|
|
.sum::<f64>()
|
|
/ self.count
|
|
}
|
|
|
|
/// Estimates the population standard deviation from [`Self::estimated_stdvar`].
|
|
pub fn estimated_stddev(&self) -> f64 {
|
|
self.estimated_stdvar().sqrt()
|
|
}
|
|
|
|
/// Multiplies every payload value by `factor`.
|
|
///
|
|
/// A negative factor marks the result as a gauge histogram.
|
|
pub fn scale(mut self, factor: f64) -> Self {
|
|
self.count *= factor;
|
|
self.zero_count *= factor;
|
|
self.sum *= factor;
|
|
for count in &mut self.positive_buckets {
|
|
*count *= factor;
|
|
}
|
|
for count in &mut self.negative_buckets {
|
|
*count *= factor;
|
|
}
|
|
if factor < 0.0 {
|
|
self.reset_hint = CounterResetHint::Gauge;
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Divides every payload value by `divisor`.
|
|
///
|
|
/// A negative divisor marks the result as a gauge histogram.
|
|
pub fn divide_by(mut self, divisor: f64) -> Self {
|
|
self.count /= divisor;
|
|
self.zero_count /= divisor;
|
|
self.sum /= divisor;
|
|
if divisor == 0.0 {
|
|
self.positive_spans.clear();
|
|
self.positive_buckets.clear();
|
|
self.negative_spans.clear();
|
|
self.negative_buckets.clear();
|
|
} else {
|
|
for count in &mut self.positive_buckets {
|
|
*count /= divisor;
|
|
}
|
|
for count in &mut self.negative_buckets {
|
|
*count /= divisor;
|
|
}
|
|
}
|
|
if divisor < 0.0 {
|
|
self.reset_hint = CounterResetHint::Gauge;
|
|
}
|
|
self
|
|
}
|
|
|
|
fn compact(mut self) -> Option<Self> {
|
|
let (spans, buckets) = compact_side(&self.positive_spans, &self.positive_buckets)?;
|
|
self.positive_spans = spans;
|
|
self.positive_buckets = buckets;
|
|
let (spans, buckets) = compact_side(&self.negative_spans, &self.negative_buckets)?;
|
|
self.negative_spans = spans;
|
|
self.negative_buckets = buckets;
|
|
Some(self)
|
|
}
|
|
|
|
/// Detects a reset from the explicit hint and histogram payload.
|
|
pub fn detect_reset(&self, previous: &Self) -> bool {
|
|
match self.reset_hint {
|
|
CounterResetHint::CounterReset => return true,
|
|
CounterResetHint::NotCounterReset => return false,
|
|
CounterResetHint::Unknown
|
|
| CounterResetHint::Gauge
|
|
| CounterResetHint::Unrecognized(_) => {}
|
|
}
|
|
|
|
if self.count < previous.count {
|
|
return true;
|
|
}
|
|
|
|
match (self.uses_custom_buckets(), previous.uses_custom_buckets()) {
|
|
(true, true) => {
|
|
let Some((current, previous)) = reconcile_custom(self, previous) else {
|
|
return true;
|
|
};
|
|
current.zero_count < previous.zero_count
|
|
|| current.side_has_reset(true, &previous)
|
|
|| current.side_has_reset(false, &previous)
|
|
}
|
|
(true, false) | (false, true) => true,
|
|
(false, false) => {
|
|
if self.schema > previous.schema || self.zero_threshold < previous.zero_threshold {
|
|
return true;
|
|
}
|
|
|
|
let mut previous = previous.clone();
|
|
if self.zero_threshold > previous.zero_threshold {
|
|
let Some(expanded) = previous.expanded_zero_threshold(self.zero_threshold)
|
|
else {
|
|
return true;
|
|
};
|
|
if expanded != self.zero_threshold
|
|
|| previous.grow_zero_threshold(self.zero_threshold).is_none()
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
let Some(previous) = previous.copy_to_schema(self.schema) else {
|
|
return true;
|
|
};
|
|
|
|
self.zero_count < previous.zero_count
|
|
|| self.side_has_reset(true, &previous)
|
|
|| self.side_has_reset(false, &previous)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn detect_start_timestamp_reset(
|
|
&self,
|
|
previous: &Self,
|
|
previous_ts: i64,
|
|
current_ts: i64,
|
|
) -> bool {
|
|
let current_start = self.start_timestamp.unwrap_or_default();
|
|
if current_start == 0 || current_start >= current_ts || current_start < previous_ts {
|
|
return false;
|
|
}
|
|
if current_start > previous_ts {
|
|
return true;
|
|
}
|
|
|
|
let previous_start = previous.start_timestamp.unwrap_or_default();
|
|
previous_start <= previous_ts && previous_start != 0 && previous_start != previous_ts
|
|
}
|
|
|
|
/// Detects a counter reset using start timestamps, hints, and payload values.
|
|
pub fn detect_counter_reset(&self, previous: &Self, previous_ts: i64, current_ts: i64) -> bool {
|
|
self.detect_start_timestamp_reset(previous, previous_ts, current_ts)
|
|
|| self.detect_reset(previous)
|
|
}
|
|
|
|
fn all_buckets(&self) -> Option<Vec<Bucket>> {
|
|
let mut buckets = self.side_buckets(false)?;
|
|
buckets.reverse();
|
|
if self.zero_count != 0.0 {
|
|
buckets.push(Bucket {
|
|
lower: -self.zero_threshold,
|
|
upper: self.zero_threshold,
|
|
count: self.zero_count,
|
|
boundary_rule: BoundaryRule::ClosedBoth,
|
|
});
|
|
}
|
|
buckets.extend(self.side_buckets(true)?);
|
|
Some(buckets)
|
|
}
|
|
|
|
fn side_buckets(&self, positive: bool) -> Option<Vec<Bucket>> {
|
|
let (spans, counts) = if positive {
|
|
(&self.positive_spans, &self.positive_buckets)
|
|
} else {
|
|
(&self.negative_spans, &self.negative_buckets)
|
|
};
|
|
|
|
build_side_buckets(
|
|
spans.iter().map(|span| (span.offset, span.length)),
|
|
counts,
|
|
self.schema,
|
|
&self.custom_values,
|
|
positive,
|
|
)
|
|
}
|
|
|
|
/// Estimates the value at quantile `q`.
|
|
///
|
|
/// Returns negative or positive infinity outside `[0, 1]`, and `NaN` when
|
|
/// the quantile cannot be estimated.
|
|
pub fn quantile(&self, q: f64) -> f64 {
|
|
self.quantile_with_info(q).0
|
|
}
|
|
|
|
/// Estimates the value at quantile `q` and reports the effect of NaN observations.
|
|
pub fn quantile_with_info(&self, q: f64) -> (f64, Option<NativeHistogramQuantileInfo>) {
|
|
quantile_from_parts(
|
|
q,
|
|
self.schema,
|
|
self.count,
|
|
self.sum,
|
|
self.negative_buckets.is_empty(),
|
|
self.positive_buckets.is_empty(),
|
|
|| self.all_buckets(),
|
|
)
|
|
}
|
|
|
|
/// Estimates the fraction of observations between `lower` and `upper`.
|
|
pub fn fraction(&self, lower: f64, upper: f64) -> f64 {
|
|
self.fraction_with_info(lower, upper).0
|
|
}
|
|
|
|
/// Estimates a fraction and reports whether NaN observations were excluded.
|
|
pub fn fraction_with_info(&self, lower: f64, upper: f64) -> (f64, bool) {
|
|
if self.count == 0.0 || lower.is_nan() || upper.is_nan() {
|
|
return (f64::NAN, false);
|
|
}
|
|
if lower >= upper {
|
|
return (0.0, false);
|
|
}
|
|
|
|
let Some(mut buckets) = self.all_buckets() else {
|
|
return (f64::NAN, false);
|
|
};
|
|
let count = if self.sum.is_nan() {
|
|
buckets.iter().map(|bucket| bucket.count).sum()
|
|
} else {
|
|
self.count
|
|
};
|
|
|
|
let mut rank = 0.0;
|
|
let mut lower_rank = 0.0;
|
|
let mut upper_rank = 0.0;
|
|
let mut lower_set = false;
|
|
let mut upper_set = false;
|
|
|
|
for bucket in &mut buckets {
|
|
let zero_bucket = bucket.lower <= 0.0 && bucket.upper >= 0.0;
|
|
if zero_bucket {
|
|
if self.negative_buckets.is_empty() && !self.positive_buckets.is_empty() {
|
|
bucket.lower = 0.0;
|
|
} else if self.positive_buckets.is_empty() && !self.negative_buckets.is_empty() {
|
|
bucket.upper = 0.0;
|
|
}
|
|
}
|
|
|
|
if !lower_set && bucket.lower >= lower {
|
|
lower_rank = rank;
|
|
lower_set = true;
|
|
}
|
|
if !upper_set && bucket.lower >= upper {
|
|
upper_rank = rank;
|
|
upper_set = true;
|
|
}
|
|
if lower_set && upper_set {
|
|
break;
|
|
}
|
|
if !lower_set && bucket.lower < lower && bucket.upper > lower {
|
|
lower_rank = self.interpolate_rank(bucket, rank, lower, zero_bucket);
|
|
lower_set = true;
|
|
}
|
|
if !upper_set && bucket.lower < upper && bucket.upper > upper {
|
|
upper_rank = self.interpolate_rank(bucket, rank, upper, zero_bucket);
|
|
upper_set = true;
|
|
}
|
|
if lower_set && upper_set {
|
|
break;
|
|
}
|
|
rank += bucket.count;
|
|
}
|
|
|
|
if !lower_set || lower_rank > count {
|
|
lower_rank = count;
|
|
}
|
|
if !upper_set || upper_rank > count {
|
|
upper_rank = count;
|
|
}
|
|
|
|
(
|
|
(upper_rank - lower_rank) / self.count,
|
|
self.sum.is_nan() && count < self.count,
|
|
)
|
|
}
|
|
|
|
/// Converts populated buckets to `(boundary rule, lower, upper, count)` strings.
|
|
pub fn to_prometheus_buckets(&self) -> Option<Vec<(u8, String, String, String)>> {
|
|
Some(
|
|
self.all_buckets()?
|
|
.into_iter()
|
|
.filter(|bucket| bucket.count != 0.0)
|
|
.map(|bucket| {
|
|
(
|
|
bucket.boundary_rule as u8,
|
|
format_prometheus_float(bucket.lower),
|
|
format_prometheus_float(bucket.upper),
|
|
format_prometheus_float(bucket.count),
|
|
)
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn interpolate_rank(&self, bucket: &Bucket, rank: f64, value: f64, zero_bucket: bool) -> f64 {
|
|
if self.uses_custom_buckets() || zero_bucket {
|
|
if bucket.lower == f64::NEG_INFINITY {
|
|
return bucket.count;
|
|
}
|
|
return rank + bucket.count * (value - bucket.lower) / (bucket.upper - bucket.lower);
|
|
}
|
|
|
|
let log_lower = bucket.lower.abs().log2();
|
|
let log_upper = bucket.upper.abs().log2();
|
|
let log_value = value.abs().log2();
|
|
let fraction = if value > 0.0 {
|
|
(log_value - log_lower) / (log_upper - log_lower)
|
|
} else {
|
|
1.0 - ((log_value - log_upper) / (log_lower - log_upper))
|
|
};
|
|
rank + bucket.count * fraction
|
|
}
|
|
|
|
fn bucket_midpoint(&self, bucket: &Bucket) -> f64 {
|
|
if self.uses_custom_buckets() {
|
|
return (bucket.lower + bucket.upper) / 2.0;
|
|
}
|
|
if bucket.lower <= 0.0 && bucket.upper >= 0.0 {
|
|
return 0.0;
|
|
}
|
|
if bucket.upper < 0.0 {
|
|
-((bucket.lower.abs() * bucket.upper.abs()).sqrt())
|
|
} else {
|
|
(bucket.lower * bucket.upper).sqrt()
|
|
}
|
|
}
|
|
|
|
fn side_has_reset(&self, positive: bool, previous: &Self) -> bool {
|
|
let (current_spans, current_buckets, previous_spans, previous_buckets) = if positive {
|
|
(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
&previous.positive_spans,
|
|
&previous.positive_buckets,
|
|
)
|
|
} else {
|
|
(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
&previous.negative_spans,
|
|
&previous.negative_buckets,
|
|
)
|
|
};
|
|
let Some(current) = side_counts(current_spans, current_buckets) else {
|
|
return true;
|
|
};
|
|
let Some(previous) = side_counts(previous_spans, previous_buckets) else {
|
|
return true;
|
|
};
|
|
previous.keys().chain(current.keys()).any(|idx| {
|
|
current.get(idx).copied().unwrap_or_default()
|
|
< previous.get(idx).copied().unwrap_or_default()
|
|
})
|
|
}
|
|
|
|
fn reconcile(&self, other: &Self) -> Option<(Self, Self)> {
|
|
match (self.uses_custom_buckets(), other.uses_custom_buckets()) {
|
|
(true, true) => reconcile_custom(self, other),
|
|
(false, false) => reconcile_exponential(self, other),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn reconcile_exponential(
|
|
left: &NativeHistogram,
|
|
right: &NativeHistogram,
|
|
) -> Option<(NativeHistogram, NativeHistogram)> {
|
|
let schema = left.schema.min(right.schema);
|
|
let mut left = left.copy_to_schema(schema)?;
|
|
let mut right = right.copy_to_schema(schema)?;
|
|
let mut zero_threshold = left.zero_threshold.max(right.zero_threshold);
|
|
loop {
|
|
let expanded = left
|
|
.expanded_zero_threshold(zero_threshold)?
|
|
.max(right.expanded_zero_threshold(zero_threshold)?);
|
|
if expanded == zero_threshold {
|
|
break;
|
|
}
|
|
zero_threshold = expanded;
|
|
}
|
|
left.grow_zero_threshold(zero_threshold)?;
|
|
right.grow_zero_threshold(zero_threshold)?;
|
|
Some((left.compact()?, right.compact()?))
|
|
}
|
|
|
|
fn reconcile_custom(
|
|
left: &NativeHistogram,
|
|
right: &NativeHistogram,
|
|
) -> Option<(NativeHistogram, NativeHistogram)> {
|
|
let custom_values = if left.custom_values == right.custom_values {
|
|
left.custom_values.clone()
|
|
} else {
|
|
left.custom_values
|
|
.iter()
|
|
.copied()
|
|
.filter(|value| right.custom_values.contains(value))
|
|
.collect()
|
|
};
|
|
|
|
Some((
|
|
left.copy_to_custom_values(custom_values.clone())?
|
|
.compact()?,
|
|
right.copy_to_custom_values(custom_values)?.compact()?,
|
|
))
|
|
}
|
|
|
|
impl NativeHistogram {
|
|
fn copy_to_schema(&self, target_schema: i32) -> Option<Self> {
|
|
if self.uses_custom_buckets()
|
|
|| !(MIN_EXPONENTIAL_SCHEMA..=MAX_EXPONENTIAL_SCHEMA).contains(&target_schema)
|
|
|| target_schema > self.schema
|
|
{
|
|
return None;
|
|
}
|
|
if target_schema == self.schema {
|
|
return Some(self.clone());
|
|
}
|
|
|
|
let mut result = self.clone();
|
|
result.schema = target_schema;
|
|
(result.positive_spans, result.positive_buckets) = reduce_side(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
self.schema,
|
|
target_schema,
|
|
)?;
|
|
(result.negative_spans, result.negative_buckets) = reduce_side(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
self.schema,
|
|
target_schema,
|
|
)?;
|
|
Some(result)
|
|
}
|
|
|
|
fn copy_to_custom_values(&self, custom_values: Vec<f64>) -> Option<Self> {
|
|
if !self.uses_custom_buckets() {
|
|
return None;
|
|
}
|
|
if self.custom_values == custom_values {
|
|
return Some(self.clone());
|
|
}
|
|
|
|
let mut result = self.clone();
|
|
result.custom_values = custom_values.clone();
|
|
result.negative_spans.clear();
|
|
result.negative_buckets.clear();
|
|
(result.positive_spans, result.positive_buckets) = map_custom_side(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
&self.custom_values,
|
|
&custom_values,
|
|
)?;
|
|
Some(result)
|
|
}
|
|
|
|
fn grow_zero_threshold(&mut self, zero_threshold: f64) -> Option<()> {
|
|
if self.uses_custom_buckets() || zero_threshold == self.zero_threshold {
|
|
self.zero_threshold = zero_threshold;
|
|
return Some(());
|
|
}
|
|
|
|
let (spans, buckets, zero_count) = fold_zero_side(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
self.schema,
|
|
zero_threshold,
|
|
)?;
|
|
self.positive_spans = spans;
|
|
self.positive_buckets = buckets;
|
|
self.zero_count += zero_count;
|
|
|
|
let (spans, buckets, zero_count) = fold_zero_side(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
self.schema,
|
|
zero_threshold,
|
|
)?;
|
|
self.negative_spans = spans;
|
|
self.negative_buckets = buckets;
|
|
self.zero_count += zero_count;
|
|
self.zero_threshold = zero_threshold;
|
|
Some(())
|
|
}
|
|
|
|
fn expanded_zero_threshold(&self, mut zero_threshold: f64) -> Option<f64> {
|
|
if self.uses_custom_buckets() {
|
|
return Some(zero_threshold);
|
|
}
|
|
zero_threshold = expand_zero_threshold_side(
|
|
&self.positive_spans,
|
|
&self.positive_buckets,
|
|
self.schema,
|
|
zero_threshold,
|
|
)?;
|
|
expand_zero_threshold_side(
|
|
&self.negative_spans,
|
|
&self.negative_buckets,
|
|
self.schema,
|
|
zero_threshold,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn add_reset_hint(left: CounterResetHint, right: CounterResetHint) -> CounterResetHint {
|
|
if left == CounterResetHint::Gauge || right == CounterResetHint::Gauge {
|
|
CounterResetHint::Gauge
|
|
} else if left == right {
|
|
left
|
|
} else {
|
|
CounterResetHint::Unknown
|
|
}
|
|
}
|
|
|
|
/// Estimates a quantile from histogram metadata and a lazily materialized bucket sequence.
|
|
fn quantile_from_parts(
|
|
q: f64,
|
|
schema: i32,
|
|
count_total: f64,
|
|
sum: f64,
|
|
negative_empty: bool,
|
|
positive_empty: bool,
|
|
buckets: impl FnOnce() -> Option<Vec<Bucket>>,
|
|
) -> (f64, Option<NativeHistogramQuantileInfo>) {
|
|
if q < 0.0 {
|
|
return (f64::NEG_INFINITY, None);
|
|
}
|
|
if q > 1.0 {
|
|
return (f64::INFINITY, None);
|
|
}
|
|
if count_total == 0.0 || q.is_nan() {
|
|
return (f64::NAN, None);
|
|
}
|
|
let Some(mut buckets) = buckets() else {
|
|
return (f64::NAN, None);
|
|
};
|
|
let bucket_total = buckets.iter().map(|bucket| bucket.count).sum::<f64>();
|
|
let has_nan_observations = sum.is_nan() && bucket_total < count_total;
|
|
let info = has_nan_observations.then_some(NativeHistogramQuantileInfo::NaNSkew);
|
|
let rank = q * count_total;
|
|
let mut count = 0.0;
|
|
for bucket in &mut buckets {
|
|
if bucket.count == 0.0 {
|
|
continue;
|
|
}
|
|
count += bucket.count;
|
|
if count < rank {
|
|
continue;
|
|
}
|
|
if schema != CUSTOM_BUCKETS_SCHEMA && bucket.lower < 0.0 && bucket.upper > 0.0 {
|
|
if negative_empty && !positive_empty {
|
|
bucket.lower = 0.0;
|
|
} else if positive_empty && !negative_empty {
|
|
bucket.upper = 0.0;
|
|
}
|
|
} else if schema == CUSTOM_BUCKETS_SCHEMA {
|
|
if bucket.lower == f64::NEG_INFINITY {
|
|
if bucket.upper <= 0.0 {
|
|
return (bucket.upper, info);
|
|
}
|
|
bucket.lower = 0.0;
|
|
} else if bucket.upper == f64::INFINITY {
|
|
return (bucket.lower, info);
|
|
}
|
|
}
|
|
let rank_in_bucket = rank - (count - bucket.count);
|
|
let fraction = rank_in_bucket / bucket.count;
|
|
if schema == CUSTOM_BUCKETS_SCHEMA || (bucket.lower <= 0.0 && bucket.upper >= 0.0) {
|
|
return (
|
|
bucket.lower + (bucket.upper - bucket.lower) * fraction,
|
|
info,
|
|
);
|
|
}
|
|
let log_lower = bucket.lower.abs().log2();
|
|
let log_upper = bucket.upper.abs().log2();
|
|
if bucket.lower > 0.0 {
|
|
return (
|
|
2.0_f64.powf(log_lower + (log_upper - log_lower) * fraction),
|
|
info,
|
|
);
|
|
}
|
|
return (
|
|
-2.0_f64.powf(log_upper + (log_lower - log_upper) * (1.0 - fraction)),
|
|
info,
|
|
);
|
|
}
|
|
let info = if sum.is_nan() && count < count_total {
|
|
Some(if count < rank {
|
|
NativeHistogramQuantileInfo::NaNResult
|
|
} else {
|
|
NativeHistogramQuantileInfo::NaNSkew
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
(f64::NAN, info)
|
|
}
|
|
|
|
/// Quantile entrypoint operating directly on the columnar borrowed view.
|
|
pub fn quantile_histogram_view(
|
|
view: &HistogramView<'_>,
|
|
q: f64,
|
|
) -> (f64, Option<NativeHistogramQuantileInfo>) {
|
|
quantile_from_parts(
|
|
q,
|
|
view.schema,
|
|
view.count,
|
|
view.sum,
|
|
view.negative.buckets.is_empty(),
|
|
view.positive.buckets.is_empty(),
|
|
|| {
|
|
let mut buckets =
|
|
build_view_side(view.negative, view.schema, view.custom_values, false)?;
|
|
buckets.reverse();
|
|
if view.zero_count != 0.0 {
|
|
buckets.push(Bucket {
|
|
lower: -view.zero_threshold,
|
|
upper: view.zero_threshold,
|
|
count: view.zero_count,
|
|
boundary_rule: BoundaryRule::ClosedBoth,
|
|
});
|
|
}
|
|
buckets.extend(build_view_side(
|
|
view.positive,
|
|
view.schema,
|
|
view.custom_values,
|
|
true,
|
|
)?);
|
|
Some(buckets)
|
|
},
|
|
)
|
|
}
|
|
|
|
fn build_view_side(
|
|
side: BucketSideView<'_>,
|
|
schema: i32,
|
|
custom_values: &[f64],
|
|
positive: bool,
|
|
) -> Option<Vec<Bucket>> {
|
|
if side.span_offsets.len() != side.span_lengths.len() {
|
|
return None;
|
|
}
|
|
let mut total = 0usize;
|
|
for (span_index, (&offset, &length)) in
|
|
side.span_offsets.iter().zip(side.span_lengths).enumerate()
|
|
{
|
|
if length < 0 || (span_index > 0 && offset < 0) {
|
|
return None;
|
|
}
|
|
total = total.checked_add(length as usize)?;
|
|
}
|
|
if total != side.buckets.len() {
|
|
return None;
|
|
}
|
|
build_side_buckets(
|
|
side.span_offsets
|
|
.iter()
|
|
.copied()
|
|
.zip(side.span_lengths.iter().copied()),
|
|
side.buckets,
|
|
schema,
|
|
custom_values,
|
|
positive,
|
|
)
|
|
}
|
|
|
|
fn build_side_buckets(
|
|
spans: impl Iterator<Item = (i32, i32)>,
|
|
counts: &[f64],
|
|
schema: i32,
|
|
custom_values: &[f64],
|
|
positive: bool,
|
|
) -> Option<Vec<Bucket>> {
|
|
let indices = bucket_indices(spans)?;
|
|
let mut result = Vec::with_capacity(counts.len());
|
|
for (idx, count) in indices.into_iter().zip(counts) {
|
|
let upper = get_bound(idx, schema, custom_values)?;
|
|
let lower = get_bound(idx.checked_sub(1)?, schema, custom_values)?;
|
|
result.push(if positive {
|
|
Bucket {
|
|
lower,
|
|
upper,
|
|
count: *count,
|
|
boundary_rule: if schema == CUSTOM_BUCKETS_SCHEMA && idx == 0 {
|
|
BoundaryRule::ClosedBoth
|
|
} else {
|
|
BoundaryRule::OpenLeft
|
|
},
|
|
}
|
|
} else {
|
|
Bucket {
|
|
lower: -upper,
|
|
upper: -lower,
|
|
count: *count,
|
|
boundary_rule: BoundaryRule::OpenRight,
|
|
}
|
|
});
|
|
}
|
|
Some(result)
|
|
}
|
|
|
|
fn bucket_indices(spans: impl Iterator<Item = (i32, i32)>) -> Option<Vec<i32>> {
|
|
let mut indices = Vec::new();
|
|
let mut current_index = 0i32;
|
|
let mut first = true;
|
|
for (span_index, (offset, length)) in spans.enumerate() {
|
|
if span_index > 0 && offset < 0 {
|
|
return None;
|
|
}
|
|
if first {
|
|
current_index = offset;
|
|
first = false;
|
|
} else {
|
|
current_index = current_index.checked_add(offset)?;
|
|
}
|
|
for _ in 0..length {
|
|
indices.push(current_index);
|
|
current_index = current_index.checked_add(1)?;
|
|
}
|
|
}
|
|
Some(indices)
|
|
}
|
|
|
|
fn side_bucket_indices(spans: &[Span]) -> Option<Vec<i32>> {
|
|
bucket_indices(spans.iter().map(|span| (span.offset, span.length)))
|
|
}
|
|
|
|
fn span_bucket_len(spans: &[Span]) -> Option<usize> {
|
|
spans
|
|
.iter()
|
|
.try_fold(0usize, |sum, span| sum.checked_add(span.length as usize))
|
|
}
|
|
|
|
fn spans_from_indices_counts(values: Vec<(i32, f64)>) -> Option<(Vec<Span>, Vec<f64>)> {
|
|
let mut spans = Vec::<Span>::new();
|
|
let mut buckets = Vec::new();
|
|
let mut previous_index = None::<i32>;
|
|
|
|
for (idx, count) in values {
|
|
if count == 0.0 {
|
|
continue;
|
|
}
|
|
match (spans.last_mut(), previous_index) {
|
|
(Some(span), Some(previous)) if previous.checked_add(1) == Some(idx) => {
|
|
span.length = span.length.checked_add(1)?;
|
|
}
|
|
(_, Some(previous)) => {
|
|
spans.push(Span {
|
|
offset: idx.checked_sub(previous)?.checked_sub(1)?,
|
|
length: 1,
|
|
});
|
|
}
|
|
(_, None) => {
|
|
spans.push(Span {
|
|
offset: idx,
|
|
length: 1,
|
|
});
|
|
}
|
|
}
|
|
buckets.push(count);
|
|
previous_index = Some(idx);
|
|
}
|
|
|
|
Some((spans, buckets))
|
|
}
|
|
|
|
fn side_counts(spans: &[Span], buckets: &[f64]) -> Option<BTreeMap<i32, f64>> {
|
|
let mut values = BTreeMap::new();
|
|
for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
|
|
if *count != 0.0 {
|
|
values.insert(idx, *count);
|
|
}
|
|
}
|
|
Some(values)
|
|
}
|
|
|
|
fn side_layout_equal(
|
|
left_spans: &[Span],
|
|
left_buckets: &[f64],
|
|
right_spans: &[Span],
|
|
right_buckets: &[f64],
|
|
) -> bool {
|
|
if span_bucket_len(left_spans) != Some(left_buckets.len())
|
|
|| span_bucket_len(right_spans) != Some(right_buckets.len())
|
|
{
|
|
return false;
|
|
}
|
|
let Some(left_indices) = side_bucket_indices(left_spans) else {
|
|
return false;
|
|
};
|
|
let Some(right_indices) = side_bucket_indices(right_spans) else {
|
|
return false;
|
|
};
|
|
left_indices == right_indices
|
|
&& left_buckets
|
|
.iter()
|
|
.zip(right_buckets)
|
|
.all(|(left, right)| left.to_bits() == right.to_bits())
|
|
}
|
|
|
|
fn merge_side(
|
|
left_spans: &[Span],
|
|
left_buckets: &[f64],
|
|
right_spans: &[Span],
|
|
right_buckets: &[f64],
|
|
op: impl Fn(f64, f64) -> f64,
|
|
) -> Option<(Vec<Span>, Vec<f64>)> {
|
|
let left = side_counts(left_spans, left_buckets)?;
|
|
let right = side_counts(right_spans, right_buckets)?;
|
|
let mut values = BTreeMap::new();
|
|
for idx in left.keys().chain(right.keys()) {
|
|
values.insert(
|
|
*idx,
|
|
op(
|
|
left.get(idx).copied().unwrap_or_default(),
|
|
right.get(idx).copied().unwrap_or_default(),
|
|
),
|
|
);
|
|
}
|
|
spans_from_indices_counts(values.into_iter().collect())
|
|
}
|
|
|
|
fn reduce_side(
|
|
spans: &[Span],
|
|
buckets: &[f64],
|
|
schema: i32,
|
|
target_schema: i32,
|
|
) -> Option<(Vec<Span>, Vec<f64>)> {
|
|
let factor = 1_i32.checked_shl((schema - target_schema) as u32)?;
|
|
let mut values = std::collections::BTreeMap::<i32, f64>::new();
|
|
for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
|
|
let target_idx = ceil_div(idx, factor);
|
|
*values.entry(target_idx).or_default() += *count;
|
|
}
|
|
spans_from_indices_counts(values.into_iter().collect())
|
|
}
|
|
|
|
fn compact_side(spans: &[Span], buckets: &[f64]) -> Option<(Vec<Span>, Vec<f64>)> {
|
|
let indices = side_bucket_indices(spans)?;
|
|
spans_from_indices_counts(indices.into_iter().zip(buckets.iter().copied()).collect())
|
|
}
|
|
|
|
fn map_custom_side(
|
|
spans: &[Span],
|
|
buckets: &[f64],
|
|
old_values: &[f64],
|
|
new_values: &[f64],
|
|
) -> Option<(Vec<Span>, Vec<f64>)> {
|
|
let mut values = std::collections::BTreeMap::<i32, f64>::new();
|
|
for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
|
|
let upper = get_bound(idx, CUSTOM_BUCKETS_SCHEMA, old_values)?;
|
|
let target_idx = new_values
|
|
.iter()
|
|
.position(|value| *value >= upper)
|
|
.unwrap_or(new_values.len()) as i32;
|
|
*values.entry(target_idx).or_default() += *count;
|
|
}
|
|
spans_from_indices_counts(values.into_iter().collect())
|
|
}
|
|
|
|
fn fold_zero_side(
|
|
spans: &[Span],
|
|
buckets: &[f64],
|
|
schema: i32,
|
|
zero_threshold: f64,
|
|
) -> Option<(Vec<Span>, Vec<f64>, f64)> {
|
|
let mut kept = Vec::new();
|
|
let mut zero_count = 0.0;
|
|
for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
|
|
if get_bound(idx, schema, &[])? <= zero_threshold {
|
|
zero_count += *count;
|
|
} else {
|
|
kept.push((idx, *count));
|
|
}
|
|
}
|
|
let (spans, buckets) = spans_from_indices_counts(kept)?;
|
|
Some((spans, buckets, zero_count))
|
|
}
|
|
|
|
fn expand_zero_threshold_side(
|
|
spans: &[Span],
|
|
buckets: &[f64],
|
|
schema: i32,
|
|
mut zero_threshold: f64,
|
|
) -> Option<f64> {
|
|
for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
|
|
if *count == 0.0 {
|
|
continue;
|
|
}
|
|
let lower = get_bound(idx.checked_sub(1)?, schema, &[])?;
|
|
let upper = get_bound(idx, schema, &[])?;
|
|
if lower < zero_threshold && zero_threshold < upper {
|
|
zero_threshold = upper;
|
|
}
|
|
}
|
|
Some(zero_threshold)
|
|
}
|
|
|
|
fn ceil_div(value: i32, divisor: i32) -> i32 {
|
|
value.div_euclid(divisor) + i32::from(value.rem_euclid(divisor) != 0)
|
|
}
|
|
|
|
/// Returns the index of the exponential bucket containing positive infinity.
|
|
pub fn exponential_overflow_bucket_index(schema: i32) -> Option<i32> {
|
|
if !(MIN_EXPONENTIAL_SCHEMA..=MAX_EXPONENTIAL_SCHEMA).contains(&schema) {
|
|
return None;
|
|
}
|
|
let last_finite = if schema >= 0 {
|
|
1024_i64.checked_shl(schema as u32)?
|
|
} else {
|
|
1024_i64.checked_shr((-schema) as u32)?
|
|
};
|
|
i32::try_from(last_finite.checked_add(1)?).ok()
|
|
}
|
|
|
|
fn get_bound(idx: i32, schema: i32, custom_values: &[f64]) -> Option<f64> {
|
|
if schema == CUSTOM_BUCKETS_SCHEMA {
|
|
return match idx {
|
|
-1 => Some(f64::NEG_INFINITY),
|
|
idx if idx == custom_values.len() as i32 => Some(f64::INFINITY),
|
|
idx if idx >= 0 && (idx as usize) < custom_values.len() => {
|
|
Some(custom_values[idx as usize])
|
|
}
|
|
_ => None,
|
|
};
|
|
}
|
|
|
|
let overflow_index = exponential_overflow_bucket_index(schema)?;
|
|
if idx > overflow_index {
|
|
return None;
|
|
}
|
|
if idx == overflow_index {
|
|
return Some(f64::INFINITY);
|
|
}
|
|
if idx == overflow_index - 1 {
|
|
return Some(f64::MAX);
|
|
}
|
|
if schema < 0 {
|
|
let exponent = i64::from(idx).checked_shl((-schema) as u32)?;
|
|
let Ok(exponent) = i32::try_from(exponent) else {
|
|
return Some(0.0);
|
|
};
|
|
return Some(2.0_f64.powi(exponent));
|
|
}
|
|
let exponent = idx as f64 / (1u32 << schema) as f64;
|
|
Some(2.0_f64.powf(exponent))
|
|
}
|
|
|
|
/// Returns the Arrow type for a complete native histogram value.
|
|
pub fn native_histogram_arrow_type() -> ArrowDataType {
|
|
native_histogram_value_type().as_arrow_type()
|
|
}
|
|
|
|
fn struct_child<'a>(array: &'a StructArray, name: &str) -> DfResult<&'a ArrayRef> {
|
|
let index = array
|
|
.fields()
|
|
.iter()
|
|
.position(|field| field.name() == name)
|
|
.ok_or_else(|| {
|
|
DataFusionError::Execution(format!("native histogram missing field {name}"))
|
|
})?;
|
|
Ok(array.column(index))
|
|
}
|
|
|
|
fn primitive_child<'a, T>(array: &'a StructArray, name: &str) -> DfResult<&'a PrimitiveArray<T>>
|
|
where
|
|
T: ArrowPrimitiveType,
|
|
{
|
|
let child = struct_child(array, name)?;
|
|
child
|
|
.as_any()
|
|
.downcast_ref::<PrimitiveArray<T>>()
|
|
.ok_or_else(|| {
|
|
DataFusionError::Execution(format!(
|
|
"native histogram field {name} has invalid type {}",
|
|
child.data_type()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn list_child<'a>(array: &'a StructArray, name: &str) -> DfResult<&'a ListArray> {
|
|
let child = struct_child(array, name)?;
|
|
child.as_any().downcast_ref::<ListArray>().ok_or_else(|| {
|
|
DataFusionError::Execution(format!(
|
|
"native histogram field {name} has invalid type {}",
|
|
child.data_type()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn required_primitive<T>(array: &StructArray, name: &str, row: usize) -> DfResult<T::Native>
|
|
where
|
|
T: ArrowPrimitiveType,
|
|
{
|
|
let values = primitive_child::<T>(array, name)?;
|
|
if values.is_null(row) {
|
|
return Err(DataFusionError::Execution(format!(
|
|
"native histogram field {name} is null"
|
|
)));
|
|
}
|
|
Ok(values.value(row))
|
|
}
|
|
|
|
fn optional_primitive<T>(array: &StructArray, name: &str, row: usize) -> DfResult<Option<T::Native>>
|
|
where
|
|
T: ArrowPrimitiveType,
|
|
{
|
|
let values = primitive_child::<T>(array, name)?;
|
|
Ok((!values.is_null(row)).then(|| values.value(row)))
|
|
}
|
|
|
|
fn list_values<T>(array: &StructArray, name: &str, row: usize) -> DfResult<Vec<T::Native>>
|
|
where
|
|
T: ArrowPrimitiveType,
|
|
{
|
|
let list = list_child(array, name)?;
|
|
if list.is_null(row) {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let values = list.value(row);
|
|
let values = values
|
|
.as_any()
|
|
.downcast_ref::<PrimitiveArray<T>>()
|
|
.ok_or_else(|| {
|
|
DataFusionError::Execution(format!(
|
|
"native histogram list field {name} has invalid value type {}",
|
|
values.data_type()
|
|
))
|
|
})?;
|
|
|
|
values
|
|
.iter()
|
|
.map(|value| {
|
|
value.ok_or_else(|| {
|
|
DataFusionError::Execution(format!(
|
|
"native histogram list field {name} contains null"
|
|
))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn read_spans(offsets: Vec<i32>, lengths: Vec<i32>, name: &str) -> DfResult<Vec<Span>> {
|
|
if offsets.len() != lengths.len() {
|
|
return Err(DataFusionError::Execution(format!(
|
|
"native histogram {name} span offsets and lengths mismatch: {} vs {}",
|
|
offsets.len(),
|
|
lengths.len()
|
|
)));
|
|
}
|
|
offsets
|
|
.into_iter()
|
|
.zip(lengths)
|
|
.map(|(offset, length)| {
|
|
if length < 0 {
|
|
return Err(DataFusionError::Execution(format!(
|
|
"native histogram {name} span has negative length {length}"
|
|
)));
|
|
}
|
|
Ok(Span { offset, length })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn check_span_bucket_count(spans: &[Span], buckets: usize, name: &str) -> DfResult<()> {
|
|
let span_len = span_bucket_len(spans).ok_or_else(|| {
|
|
DataFusionError::Execution(format!("native histogram {name} spans overflow"))
|
|
})?;
|
|
if span_len != buckets {
|
|
return Err(DataFusionError::Execution(format!(
|
|
"native histogram {name} spans describe {span_len} buckets, found {buckets}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Decodes one native histogram row into the query-time [`NativeHistogram`] model.
|
|
///
|
|
/// A null struct returns `Ok(None)`. Integer payloads are converted to absolute
|
|
/// `f64` counts, and malformed child fields or span layouts return an error.
|
|
pub fn read_histogram(array: &StructArray, row: usize) -> DfResult<Option<NativeHistogram>> {
|
|
if array.is_null(row) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let schema = required_primitive::<Int32Type>(array, SCHEMA_FIELD, row)?;
|
|
let positive_spans = read_spans(
|
|
list_values::<Int32Type>(array, POSITIVE_SPAN_OFFSETS_FIELD, row)?,
|
|
list_values::<Int32Type>(array, POSITIVE_SPAN_LENGTHS_FIELD, row)?,
|
|
"positive",
|
|
)?;
|
|
let negative_spans = read_spans(
|
|
list_values::<Int32Type>(array, NEGATIVE_SPAN_OFFSETS_FIELD, row)?,
|
|
list_values::<Int32Type>(array, NEGATIVE_SPAN_LENGTHS_FIELD, row)?,
|
|
"negative",
|
|
)?;
|
|
|
|
let (count, zero_count, positive_buckets, negative_buckets) =
|
|
if let Some(count) = optional_primitive::<Float64Type>(array, COUNT_F64_FIELD, row)? {
|
|
(
|
|
count,
|
|
optional_primitive::<Float64Type>(array, ZERO_COUNT_F64_FIELD, row)?
|
|
.unwrap_or_default(),
|
|
list_values::<Float64Type>(array, POSITIVE_BUCKETS_F64_FIELD, row)?,
|
|
list_values::<Float64Type>(array, NEGATIVE_BUCKETS_F64_FIELD, row)?,
|
|
)
|
|
} else {
|
|
(
|
|
required_primitive::<Int64Type>(array, COUNT_I64_FIELD, row)? as f64,
|
|
optional_primitive::<Int64Type>(array, ZERO_COUNT_I64_FIELD, row)?
|
|
.unwrap_or_default() as f64,
|
|
list_values::<Int64Type>(array, POSITIVE_BUCKETS_I64_FIELD, row)?
|
|
.into_iter()
|
|
.map(|value| value as f64)
|
|
.collect(),
|
|
list_values::<Int64Type>(array, NEGATIVE_BUCKETS_I64_FIELD, row)?
|
|
.into_iter()
|
|
.map(|value| value as f64)
|
|
.collect(),
|
|
)
|
|
};
|
|
|
|
check_span_bucket_count(&positive_spans, positive_buckets.len(), "positive")?;
|
|
check_span_bucket_count(&negative_spans, negative_buckets.len(), "negative")?;
|
|
|
|
Ok(Some(NativeHistogram {
|
|
schema,
|
|
zero_threshold: required_primitive::<Float64Type>(array, ZERO_THRESHOLD_FIELD, row)?,
|
|
sum: required_primitive::<Float64Type>(array, SUM_FIELD, row)?,
|
|
reset_hint: required_primitive::<Int32Type>(array, RESET_HINT_FIELD, row)?.into(),
|
|
start_timestamp: optional_primitive::<TimestampMillisecondType>(
|
|
array,
|
|
START_TIMESTAMP_FIELD,
|
|
row,
|
|
)?,
|
|
custom_values: list_values::<Float64Type>(array, CUSTOM_VALUES_FIELD, row)?,
|
|
positive_spans,
|
|
negative_spans,
|
|
count,
|
|
zero_count,
|
|
positive_buckets,
|
|
negative_buckets,
|
|
}))
|
|
}
|
|
|
|
fn list_opt<T>(values: Vec<T>) -> Option<Vec<Option<T>>> {
|
|
Some(values.into_iter().map(Some).collect())
|
|
}
|
|
|
|
/// Builds a native histogram Struct array from query-time values.
|
|
///
|
|
/// The result uses the canonical `f64` payload fields. Integer count and bucket
|
|
/// fields remain empty because [`NativeHistogram`] no longer tracks the source
|
|
/// payload family after decoding.
|
|
pub fn build_histogram_array(values: &[Option<NativeHistogram>]) -> ArrayRef {
|
|
let mut schemas = Vec::with_capacity(values.len());
|
|
let mut zero_thresholds = Vec::with_capacity(values.len());
|
|
let mut sums = Vec::with_capacity(values.len());
|
|
let mut reset_hints = Vec::with_capacity(values.len());
|
|
let mut start_timestamps = Vec::with_capacity(values.len());
|
|
let mut custom_values = Vec::with_capacity(values.len());
|
|
let mut positive_span_offsets = Vec::with_capacity(values.len());
|
|
let mut positive_span_lengths = Vec::with_capacity(values.len());
|
|
let mut negative_span_offsets = Vec::with_capacity(values.len());
|
|
let mut negative_span_lengths = Vec::with_capacity(values.len());
|
|
let mut count_i64 = Vec::<Option<i64>>::with_capacity(values.len());
|
|
let mut zero_count_i64 = Vec::<Option<i64>>::with_capacity(values.len());
|
|
let mut positive_buckets_i64 = Vec::with_capacity(values.len());
|
|
let mut negative_buckets_i64 = Vec::with_capacity(values.len());
|
|
let mut count_f64 = Vec::with_capacity(values.len());
|
|
let mut zero_count_f64 = Vec::with_capacity(values.len());
|
|
let mut positive_buckets_f64 = Vec::with_capacity(values.len());
|
|
let mut negative_buckets_f64 = Vec::with_capacity(values.len());
|
|
let mut validity = Vec::with_capacity(values.len());
|
|
|
|
for value in values {
|
|
validity.push(value.is_some());
|
|
if let Some(histogram) = value {
|
|
schemas.push(Some(histogram.schema));
|
|
zero_thresholds.push(Some(histogram.zero_threshold));
|
|
sums.push(Some(histogram.sum));
|
|
reset_hints.push(Some(i32::from(histogram.reset_hint)));
|
|
start_timestamps.push(histogram.start_timestamp);
|
|
custom_values.push(list_opt(histogram.custom_values.clone()));
|
|
positive_span_offsets.push(list_opt(
|
|
histogram
|
|
.positive_spans
|
|
.iter()
|
|
.map(|span| span.offset)
|
|
.collect(),
|
|
));
|
|
positive_span_lengths.push(list_opt(
|
|
histogram
|
|
.positive_spans
|
|
.iter()
|
|
.map(|span| span.length)
|
|
.collect(),
|
|
));
|
|
negative_span_offsets.push(list_opt(
|
|
histogram
|
|
.negative_spans
|
|
.iter()
|
|
.map(|span| span.offset)
|
|
.collect(),
|
|
));
|
|
negative_span_lengths.push(list_opt(
|
|
histogram
|
|
.negative_spans
|
|
.iter()
|
|
.map(|span| span.length)
|
|
.collect(),
|
|
));
|
|
count_i64.push(None);
|
|
zero_count_i64.push(None);
|
|
positive_buckets_i64.push(list_opt(Vec::<i64>::new()));
|
|
negative_buckets_i64.push(list_opt(Vec::<i64>::new()));
|
|
count_f64.push(Some(histogram.count));
|
|
zero_count_f64.push(Some(histogram.zero_count));
|
|
positive_buckets_f64.push(list_opt(histogram.positive_buckets.clone()));
|
|
negative_buckets_f64.push(list_opt(histogram.negative_buckets.clone()));
|
|
} else {
|
|
schemas.push(None);
|
|
zero_thresholds.push(None);
|
|
sums.push(None);
|
|
reset_hints.push(None);
|
|
start_timestamps.push(None);
|
|
custom_values.push(None);
|
|
positive_span_offsets.push(None);
|
|
positive_span_lengths.push(None);
|
|
negative_span_offsets.push(None);
|
|
negative_span_lengths.push(None);
|
|
count_i64.push(None);
|
|
zero_count_i64.push(None);
|
|
positive_buckets_i64.push(None);
|
|
negative_buckets_i64.push(None);
|
|
count_f64.push(None);
|
|
zero_count_f64.push(None);
|
|
positive_buckets_f64.push(None);
|
|
negative_buckets_f64.push(None);
|
|
}
|
|
}
|
|
|
|
let named_arrays: Vec<(&str, ArrayRef)> = vec![
|
|
(SCHEMA_FIELD, Arc::new(Int32Array::from(schemas))),
|
|
(
|
|
ZERO_THRESHOLD_FIELD,
|
|
Arc::new(Float64Array::from(zero_thresholds)),
|
|
),
|
|
(SUM_FIELD, Arc::new(Float64Array::from(sums))),
|
|
(RESET_HINT_FIELD, Arc::new(Int32Array::from(reset_hints))),
|
|
(
|
|
START_TIMESTAMP_FIELD,
|
|
Arc::new(TimestampMillisecondArray::from_iter(start_timestamps)),
|
|
),
|
|
(
|
|
CUSTOM_VALUES_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
|
|
custom_values,
|
|
)),
|
|
),
|
|
(
|
|
POSITIVE_SPAN_OFFSETS_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
|
|
positive_span_offsets,
|
|
)),
|
|
),
|
|
(
|
|
POSITIVE_SPAN_LENGTHS_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
|
|
positive_span_lengths,
|
|
)),
|
|
),
|
|
(
|
|
NEGATIVE_SPAN_OFFSETS_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
|
|
negative_span_offsets,
|
|
)),
|
|
),
|
|
(
|
|
NEGATIVE_SPAN_LENGTHS_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
|
|
negative_span_lengths,
|
|
)),
|
|
),
|
|
(COUNT_I64_FIELD, Arc::new(Int64Array::from(count_i64))),
|
|
(
|
|
ZERO_COUNT_I64_FIELD,
|
|
Arc::new(Int64Array::from(zero_count_i64)),
|
|
),
|
|
(
|
|
POSITIVE_BUCKETS_I64_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int64Type, _, _>(
|
|
positive_buckets_i64,
|
|
)),
|
|
),
|
|
(
|
|
NEGATIVE_BUCKETS_I64_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Int64Type, _, _>(
|
|
negative_buckets_i64,
|
|
)),
|
|
),
|
|
(COUNT_F64_FIELD, Arc::new(Float64Array::from(count_f64))),
|
|
(
|
|
ZERO_COUNT_F64_FIELD,
|
|
Arc::new(Float64Array::from(zero_count_f64)),
|
|
),
|
|
(
|
|
POSITIVE_BUCKETS_F64_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
|
|
positive_buckets_f64,
|
|
)),
|
|
),
|
|
(
|
|
NEGATIVE_BUCKETS_F64_FIELD,
|
|
Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
|
|
negative_buckets_f64,
|
|
)),
|
|
),
|
|
];
|
|
let (fields, arrays): (Vec<_>, Vec<_>) = named_arrays
|
|
.into_iter()
|
|
.map(|(name, array)| (Field::new(name, array.data_type().clone(), true), array))
|
|
.unzip();
|
|
|
|
Arc::new(StructArray::new(
|
|
fields.into(),
|
|
arrays,
|
|
Some(NullBuffer::from(validity)),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use datatypes::series::histogram::HistogramBatch;
|
|
|
|
fn histogram(positive_spans: Vec<Span>, positive_buckets: Vec<f64>) -> NativeHistogram {
|
|
let count = positive_buckets.iter().sum();
|
|
NativeHistogram {
|
|
schema: 0,
|
|
zero_threshold: 0.0,
|
|
sum: count,
|
|
reset_hint: CounterResetHint::CounterReset,
|
|
start_timestamp: None,
|
|
custom_values: Vec::new(),
|
|
positive_spans,
|
|
negative_spans: Vec::new(),
|
|
count,
|
|
zero_count: 0.0,
|
|
positive_buckets,
|
|
negative_buckets: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn borrowed_quantile_matches_owned_through_batch_builder() {
|
|
let mut owned = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 2,
|
|
}],
|
|
vec![1.0, 2.0],
|
|
);
|
|
owned.count = 5.0;
|
|
owned.zero_threshold = 0.5;
|
|
owned.zero_count = 1.0;
|
|
owned.start_timestamp = Some(123456);
|
|
owned.negative_spans = vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}];
|
|
owned.negative_buckets = vec![1.0];
|
|
let mut builder = HistogramBatchBuilder::new();
|
|
assert_eq!(owned.push_to(&mut builder).unwrap(), 0);
|
|
let batch = builder.finish().unwrap();
|
|
let view = batch.view(0).unwrap();
|
|
for q in [-0.1, 0.0, 0.1, 0.3, 0.5, 1.0, 1.1, f64::NAN] {
|
|
let expected = owned.quantile_with_info(q);
|
|
let actual = quantile_histogram_view(&view, q);
|
|
assert_eq!(actual.0.to_bits(), expected.0.to_bits());
|
|
assert_eq!(actual.1, expected.1);
|
|
}
|
|
assert_eq!(view.reset_hint, owned.reset_hint);
|
|
assert_eq!(view.start_timestamp, owned.start_timestamp);
|
|
assert_eq!(view.count.to_bits(), owned.count.to_bits());
|
|
assert_eq!(view.sum.to_bits(), owned.sum.to_bits());
|
|
}
|
|
|
|
#[test]
|
|
fn owned_malformed_spans_keep_truncating_behavior() {
|
|
let malformed = NativeHistogram {
|
|
schema: 0,
|
|
zero_threshold: 0.0,
|
|
sum: 3.0,
|
|
reset_hint: CounterResetHint::Unknown,
|
|
start_timestamp: None,
|
|
custom_values: Vec::new(),
|
|
positive_spans: vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
negative_spans: Vec::new(),
|
|
count: 3.0,
|
|
zero_count: 0.0,
|
|
positive_buckets: vec![1.0, 2.0],
|
|
negative_buckets: Vec::new(),
|
|
};
|
|
assert_eq!(malformed.quantile(0.0), 0.5);
|
|
assert_eq!(malformed.fraction(0.0, 1.0), 1.0);
|
|
|
|
let negative_length = NativeHistogram {
|
|
positive_spans: vec![
|
|
Span {
|
|
offset: 0,
|
|
length: -1,
|
|
},
|
|
Span {
|
|
offset: 0,
|
|
length: 1,
|
|
},
|
|
],
|
|
positive_buckets: vec![1.0],
|
|
count: 1.0,
|
|
..histogram(Vec::new(), Vec::new())
|
|
};
|
|
assert_eq!(negative_length.quantile(0.0), 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn borrowed_custom_quantile_and_nan_info_match_owned() {
|
|
let owned = NativeHistogram {
|
|
schema: CUSTOM_BUCKETS_SCHEMA,
|
|
zero_threshold: 0.0,
|
|
sum: f64::NAN,
|
|
reset_hint: CounterResetHint::Unrecognized(91),
|
|
start_timestamp: Some(987),
|
|
custom_values: vec![-1.0, 1.0],
|
|
positive_spans: vec![Span {
|
|
offset: 0,
|
|
length: 3,
|
|
}],
|
|
negative_spans: Vec::new(),
|
|
count: 4.0,
|
|
zero_count: 0.0,
|
|
positive_buckets: vec![1.0, 1.0, 1.0],
|
|
negative_buckets: Vec::new(),
|
|
};
|
|
let mut builder = HistogramBatchBuilder::new();
|
|
owned.push_to(&mut builder).unwrap();
|
|
let batch = builder.finish().unwrap();
|
|
let view = batch.view(0).unwrap();
|
|
for q in [0.0, 0.5, 0.99, 1.0] {
|
|
let expected = owned.quantile_with_info(q);
|
|
let actual = quantile_histogram_view(&view, q);
|
|
assert_eq!(actual.0.to_bits(), expected.0.to_bits());
|
|
assert_eq!(actual.1, expected.1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn borrowed_quantile_rejects_malformed_and_overflowing_spans() {
|
|
let owned = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
let mut builder = HistogramBatchBuilder::new();
|
|
owned.push_to(&mut builder).unwrap();
|
|
let batch = builder.finish().unwrap();
|
|
let mut view = batch.view(0).unwrap();
|
|
let malformed = [
|
|
BucketSideView {
|
|
span_offsets: &[0],
|
|
span_lengths: &[],
|
|
buckets: &[1.0],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[i32::MAX],
|
|
span_lengths: &[1],
|
|
buckets: &[1.0],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[0],
|
|
span_lengths: &[-1],
|
|
buckets: &[],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[0],
|
|
span_lengths: &[2],
|
|
buckets: &[1.0],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[0],
|
|
span_lengths: &[0],
|
|
buckets: &[1.0],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[0, -1],
|
|
span_lengths: &[0, 1],
|
|
buckets: &[1.0],
|
|
},
|
|
BucketSideView {
|
|
span_offsets: &[0],
|
|
span_lengths: &[i32::MAX],
|
|
buckets: &[1.0],
|
|
},
|
|
];
|
|
for side in malformed {
|
|
view.positive = side;
|
|
let (value, info) = quantile_histogram_view(&view, 0.5);
|
|
assert!(value.is_nan());
|
|
assert_eq!(info, None);
|
|
}
|
|
assert_eq!(
|
|
quantile_histogram_view(&view, -1.0),
|
|
(f64::NEG_INFINITY, None)
|
|
);
|
|
assert_eq!(quantile_histogram_view(&view, 2.0), (f64::INFINITY, None));
|
|
}
|
|
|
|
#[test]
|
|
fn sliced_histogram_view_remains_valid_after_original_owner_drops() {
|
|
let first = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
let mut second = histogram(
|
|
vec![Span {
|
|
offset: 1,
|
|
length: 1,
|
|
}],
|
|
vec![4.0],
|
|
);
|
|
second.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
second.custom_values = vec![10.0];
|
|
second.reset_hint = CounterResetHint::Gauge;
|
|
second.start_timestamp = Some(789);
|
|
let expected_quantile = second.quantile_with_info(0.5);
|
|
let expected_bucket = second.positive_buckets[0];
|
|
let mut builder = HistogramBatchBuilder::new();
|
|
first.push_to(&mut builder).unwrap();
|
|
second.push_to(&mut builder).unwrap();
|
|
let batch = builder.finish().unwrap();
|
|
let sliced = HistogramBatch::try_from_arrow(batch.as_arrow().slice(1, 1)).unwrap();
|
|
drop(batch);
|
|
drop(first);
|
|
drop(second);
|
|
let view = sliced.view(0).unwrap();
|
|
assert_eq!(view.schema, CUSTOM_BUCKETS_SCHEMA);
|
|
assert_eq!(view.custom_values, &[10.0]);
|
|
assert_eq!(view.positive.span_offsets, &[1]);
|
|
assert_eq!(view.positive.span_lengths, &[1]);
|
|
assert_eq!(view.positive.buckets, &[expected_bucket]);
|
|
assert_eq!(view.reset_hint, CounterResetHint::Gauge);
|
|
assert_eq!(view.start_timestamp, Some(789));
|
|
assert_eq!(quantile_histogram_view(&view, 0.5), expected_quantile);
|
|
}
|
|
|
|
#[test]
|
|
fn promql_string_matches_prometheus_float_format() {
|
|
assert_eq!(format_promql_histogram_float(0.0001), "0.0001");
|
|
assert_eq!(format_promql_histogram_float(1_000_000.0), "1e+06");
|
|
|
|
let histogram = NativeHistogram {
|
|
schema: CUSTOM_BUCKETS_SCHEMA,
|
|
zero_threshold: 0.0,
|
|
sum: 2_349_209.324,
|
|
reset_hint: CounterResetHint::Unknown,
|
|
start_timestamp: None,
|
|
custom_values: vec![0.00001],
|
|
positive_spans: vec![Span {
|
|
offset: 0,
|
|
length: 2,
|
|
}],
|
|
negative_spans: Vec::new(),
|
|
count: 3.0,
|
|
zero_count: 0.0,
|
|
positive_buckets: vec![1.0, 2.0],
|
|
negative_buckets: Vec::new(),
|
|
};
|
|
|
|
assert_eq!(
|
|
histogram.promql_string(),
|
|
"{count:3, sum:2.349209324e+06, [-Inf,1e-05]:1, (1e-05,+Inf]:2}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn span_rebuild_checks_offsets_and_preserves_empty_input() {
|
|
assert_eq!(
|
|
spans_from_indices_counts(Vec::new()),
|
|
Some((Vec::new(), Vec::new()))
|
|
);
|
|
assert_eq!(
|
|
spans_from_indices_counts(vec![(2, 1.0), (3, 2.0), (5, 3.0)]),
|
|
Some((
|
|
vec![
|
|
Span {
|
|
offset: 2,
|
|
length: 2,
|
|
},
|
|
Span {
|
|
offset: 1,
|
|
length: 1,
|
|
},
|
|
],
|
|
vec![1.0, 2.0, 3.0],
|
|
))
|
|
);
|
|
assert_eq!(
|
|
spans_from_indices_counts(vec![(i32::MIN, 1.0), (i32::MAX, 2.0)]),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arrow_round_trip_preserves_unrecognized_reset_hint() {
|
|
let mut expected = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
expected.reset_hint = CounterResetHint::Unrecognized(42);
|
|
|
|
let array = build_histogram_array(&[Some(expected.clone())]);
|
|
assert_eq!(array.data_type(), &native_histogram_arrow_type());
|
|
let array = array.as_any().downcast_ref::<StructArray>().unwrap();
|
|
|
|
assert_eq!(read_histogram(array, 0).unwrap(), Some(expected));
|
|
assert!(
|
|
primitive_child::<Int64Type>(array, COUNT_I64_FIELD)
|
|
.unwrap()
|
|
.is_null(0)
|
|
);
|
|
assert_eq!(
|
|
primitive_child::<Float64Type>(array, COUNT_F64_FIELD)
|
|
.unwrap()
|
|
.value(0),
|
|
1.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_uses_sparse_bucket_union() {
|
|
let left = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
let right = histogram(
|
|
vec![Span {
|
|
offset: 2,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
|
|
let result = left.add(&right).unwrap();
|
|
assert_eq!(
|
|
result.positive_spans,
|
|
vec![
|
|
Span {
|
|
offset: 0,
|
|
length: 1,
|
|
},
|
|
Span {
|
|
offset: 1,
|
|
length: 1,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(result.positive_buckets, vec![1.0, 2.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn add_combines_reset_hints_for_compatible_empty_payloads() {
|
|
let mut empty = histogram(Vec::new(), Vec::new());
|
|
empty.reset_hint = CounterResetHint::Gauge;
|
|
let mut populated = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
populated.reset_hint = CounterResetHint::NotCounterReset;
|
|
|
|
assert_eq!(
|
|
empty.add(&populated).unwrap().reset_hint,
|
|
CounterResetHint::Gauge
|
|
);
|
|
assert_eq!(
|
|
populated.add(&empty).unwrap().reset_hint,
|
|
CounterResetHint::Gauge
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_arithmetic_rejects_incompatible_schemas() {
|
|
let exponential = histogram(Vec::new(), Vec::new());
|
|
let mut custom = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
custom.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
custom.custom_values = vec![1.0];
|
|
|
|
assert!(exponential.add(&custom).is_none());
|
|
assert!(custom.add(&exponential).is_none());
|
|
assert!(exponential.sub(&custom).is_none());
|
|
assert!(custom.sub(&exponential).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_custom_histogram_still_reconciles_bounds() {
|
|
let mut empty = histogram(Vec::new(), Vec::new());
|
|
empty.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
empty.custom_values = vec![1.0];
|
|
let mut populated = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
populated.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
populated.custom_values = vec![2.0];
|
|
|
|
let result = empty.add(&populated).unwrap();
|
|
assert!(result.custom_values.is_empty());
|
|
assert_eq!(result.positive_buckets, vec![2.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_threshold_expands_to_populated_bucket_boundary() {
|
|
let left = NativeHistogram {
|
|
zero_threshold: 0.5,
|
|
..histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
)
|
|
};
|
|
let right = NativeHistogram {
|
|
zero_threshold: 0.75,
|
|
..histogram(
|
|
vec![Span {
|
|
offset: 1,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
)
|
|
};
|
|
|
|
let result = left.add(&right).unwrap();
|
|
assert_eq!(result.zero_threshold, 1.0);
|
|
assert_eq!(result.zero_count, 1.0);
|
|
assert_eq!(result.positive_buckets, vec![1.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_reconciliation_without_shared_bounds_uses_overflow_bucket() {
|
|
let mut left = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
left.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
left.custom_values = vec![1.0];
|
|
|
|
let mut right = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
right.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
right.custom_values = vec![2.0];
|
|
|
|
let result = left.add(&right).unwrap();
|
|
assert!(result.custom_values.is_empty());
|
|
assert_eq!(
|
|
result.positive_spans,
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}]
|
|
);
|
|
assert_eq!(result.positive_buckets, vec![3.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn promql_eq_ignores_metadata_but_compares_sparse_layout() {
|
|
let mut left = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 2,
|
|
}],
|
|
vec![1.0, 0.0],
|
|
);
|
|
left.reset_hint = CounterResetHint::CounterReset;
|
|
left.start_timestamp = Some(1000);
|
|
|
|
let mut right = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
right.reset_hint = CounterResetHint::NotCounterReset;
|
|
right.start_timestamp = Some(2000);
|
|
|
|
assert!(!left.promql_eq(&right));
|
|
|
|
right.positive_spans = vec![
|
|
Span {
|
|
offset: 0,
|
|
length: 0,
|
|
},
|
|
Span {
|
|
offset: 0,
|
|
length: 1,
|
|
},
|
|
Span {
|
|
offset: 42,
|
|
length: 0,
|
|
},
|
|
];
|
|
left.positive_spans = vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}];
|
|
left.positive_buckets = vec![1.0];
|
|
assert!(left.promql_eq(&right));
|
|
}
|
|
|
|
#[test]
|
|
fn promql_eq_compares_payload_floats_by_bits() {
|
|
let nan = f64::from_bits(0x7ff8_0000_0000_0001);
|
|
let other_nan = f64::from_bits(0x7ff8_0000_0000_0002);
|
|
let mut left = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![nan],
|
|
);
|
|
left.count = nan;
|
|
left.zero_count = nan;
|
|
left.sum = nan;
|
|
let right = left.clone();
|
|
|
|
assert!(left.promql_eq(&right));
|
|
|
|
for changed in [
|
|
NativeHistogram {
|
|
count: other_nan,
|
|
..right.clone()
|
|
},
|
|
NativeHistogram {
|
|
zero_count: other_nan,
|
|
..right.clone()
|
|
},
|
|
NativeHistogram {
|
|
sum: other_nan,
|
|
..right.clone()
|
|
},
|
|
NativeHistogram {
|
|
positive_buckets: vec![other_nan],
|
|
..right.clone()
|
|
},
|
|
] {
|
|
assert!(!left.promql_eq(&changed));
|
|
}
|
|
|
|
let undecodable = histogram(
|
|
vec![Span {
|
|
offset: i32::MAX,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
assert!(!undecodable.promql_eq(&undecodable.clone()));
|
|
|
|
let mut mismatched = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 2,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
mismatched.count = 1.0;
|
|
mismatched.sum = 1.0;
|
|
assert!(!mismatched.promql_eq(&mismatched.clone()));
|
|
|
|
let invalid_offset = histogram(
|
|
vec![
|
|
Span {
|
|
offset: 0,
|
|
length: 1,
|
|
},
|
|
Span {
|
|
offset: -1,
|
|
length: 1,
|
|
},
|
|
],
|
|
vec![1.0, 1.0],
|
|
);
|
|
assert!(!invalid_offset.promql_eq(&invalid_offset.clone()));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_reset_preserves_layout_direction() {
|
|
let mut previous = histogram(
|
|
vec![Span {
|
|
offset: 1,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
previous.reset_hint = CounterResetHint::Unknown;
|
|
|
|
let mut higher_resolution = previous.clone();
|
|
higher_resolution.schema = 1;
|
|
higher_resolution.positive_spans[0].offset = 2;
|
|
assert!(higher_resolution.detect_reset(&previous));
|
|
|
|
let mut lower_resolution = previous.clone();
|
|
lower_resolution.schema = 0;
|
|
lower_resolution.positive_spans[0].offset = 1;
|
|
let mut previous_higher_resolution = higher_resolution;
|
|
previous_higher_resolution.reset_hint = CounterResetHint::Unknown;
|
|
assert!(!lower_resolution.detect_reset(&previous_higher_resolution));
|
|
|
|
let mut smaller_zero_threshold = previous.clone();
|
|
smaller_zero_threshold.zero_threshold = 0.5;
|
|
previous.zero_threshold = 1.0;
|
|
assert!(smaller_zero_threshold.detect_reset(&previous));
|
|
|
|
let mut previous = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
previous.reset_hint = CounterResetHint::Unknown;
|
|
let mut split_bucket = histogram(Vec::new(), Vec::new());
|
|
split_bucket.reset_hint = CounterResetHint::Unknown;
|
|
split_bucket.count = 1.0;
|
|
split_bucket.zero_count = 1.0;
|
|
split_bucket.zero_threshold = 0.75;
|
|
assert!(split_bucket.detect_reset(&previous));
|
|
|
|
split_bucket.zero_threshold = 1.0;
|
|
assert!(!split_bucket.detect_reset(&previous));
|
|
}
|
|
|
|
#[test]
|
|
fn extreme_bucket_index_returns_none_instead_of_overflowing() {
|
|
assert_eq!(ceil_div(i32::MIN, 2), i32::MIN / 2);
|
|
|
|
let mut histogram = histogram(
|
|
vec![Span {
|
|
offset: i32::MIN,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
assert!(histogram.side_buckets(true).is_none());
|
|
|
|
histogram.schema = 1;
|
|
let reduced = histogram.copy_to_schema(0).unwrap();
|
|
assert_eq!(reduced.positive_spans[0].offset, i32::MIN / 2);
|
|
}
|
|
|
|
#[test]
|
|
fn exponential_bucket_bounds_stop_after_overflow_bucket() {
|
|
for schema in [-4, 0, 8] {
|
|
let overflow = exponential_overflow_bucket_index(schema).unwrap();
|
|
assert_eq!(get_bound(overflow - 1, schema, &[]), Some(f64::MAX));
|
|
assert_eq!(get_bound(overflow, schema, &[]), Some(f64::INFINITY));
|
|
assert_eq!(get_bound(overflow + 1, schema, &[]), None);
|
|
}
|
|
assert_eq!(exponential_overflow_bucket_index(-5), None);
|
|
assert_eq!(exponential_overflow_bucket_index(9), None);
|
|
assert_eq!(get_bound(i32::MIN, -4, &[]), Some(0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn custom_bucket_midpoints_preserve_infinite_bounds() {
|
|
let mut histogram = histogram(Vec::new(), Vec::new());
|
|
histogram.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
|
|
assert_eq!(
|
|
histogram.bucket_midpoint(&Bucket {
|
|
lower: f64::NEG_INFINITY,
|
|
upper: -1.0,
|
|
count: 1.0,
|
|
boundary_rule: BoundaryRule::OpenLeft,
|
|
}),
|
|
f64::NEG_INFINITY
|
|
);
|
|
assert_eq!(
|
|
histogram.bucket_midpoint(&Bucket {
|
|
lower: 1.0,
|
|
upper: f64::INFINITY,
|
|
count: 1.0,
|
|
boundary_rule: BoundaryRule::OpenLeft,
|
|
}),
|
|
f64::INFINITY
|
|
);
|
|
assert!(
|
|
histogram
|
|
.bucket_midpoint(&Bucket {
|
|
lower: f64::NEG_INFINITY,
|
|
upper: f64::INFINITY,
|
|
count: 1.0,
|
|
boundary_rule: BoundaryRule::OpenLeft,
|
|
})
|
|
.is_nan()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fraction_excludes_nan_observations() {
|
|
let mut histogram = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![8.0],
|
|
);
|
|
histogram.count = 10.0;
|
|
histogram.sum = f64::NAN;
|
|
|
|
assert_eq!(
|
|
histogram.fraction_with_info(f64::NEG_INFINITY, f64::INFINITY),
|
|
(0.8, true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quantile_reports_nan_observation_effect() {
|
|
let mut histogram = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![8.0],
|
|
);
|
|
histogram.count = 10.0;
|
|
histogram.sum = f64::NAN;
|
|
|
|
let (skewed, info) = histogram.quantile_with_info(0.5);
|
|
assert!(skewed.is_finite());
|
|
assert_eq!(info, Some(NativeHistogramQuantileInfo::NaNSkew));
|
|
|
|
let (nan, info) = histogram.quantile_with_info(0.9);
|
|
assert!(nan.is_nan());
|
|
assert_eq!(info, Some(NativeHistogramQuantileInfo::NaNResult));
|
|
|
|
histogram.count = 8.0;
|
|
assert_eq!(histogram.quantile_with_info(0.5).1, None);
|
|
|
|
histogram.count = 3.0;
|
|
histogram.positive_spans.clear();
|
|
histogram.positive_buckets.clear();
|
|
let (nan, info) = histogram.quantile_with_info(0.0);
|
|
assert!(nan.is_nan());
|
|
assert_eq!(info, Some(NativeHistogramQuantileInfo::NaNSkew));
|
|
|
|
histogram.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
histogram.count = 10.0;
|
|
histogram.positive_spans = vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}];
|
|
histogram.positive_buckets = vec![8.0];
|
|
for (bound, offset, expected) in [(-1.0, 0, -1.0), (1.0, 1, 1.0)] {
|
|
histogram.custom_values = vec![bound];
|
|
histogram.positive_spans[0].offset = offset;
|
|
assert_eq!(
|
|
histogram.quantile_with_info(0.5),
|
|
(expected, Some(NativeHistogramQuantileInfo::NaNSkew))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subtraction_returns_gauge_histogram() {
|
|
let left = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![3.0],
|
|
);
|
|
let right = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![1.0],
|
|
);
|
|
|
|
let result = left.sub(&right).unwrap();
|
|
assert_eq!(result.reset_hint, CounterResetHint::Gauge);
|
|
assert_eq!(result.positive_buckets, vec![2.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn subtraction_treats_compatible_empty_left_as_zero() {
|
|
let left = histogram(vec![], vec![]);
|
|
let right = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
|
|
let result = left.sub(&right).unwrap();
|
|
assert_eq!(result.reset_hint, CounterResetHint::Gauge);
|
|
assert_eq!(result.count, -2.0);
|
|
assert_eq!(result.sum, -2.0);
|
|
assert_eq!(result.positive_buckets, vec![-2.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn subtraction_rejects_incompatible_empty_left() {
|
|
let left = histogram(vec![], vec![]);
|
|
let mut right = histogram(
|
|
vec![Span {
|
|
offset: 0,
|
|
length: 1,
|
|
}],
|
|
vec![2.0],
|
|
);
|
|
right.schema = CUSTOM_BUCKETS_SCHEMA;
|
|
right.custom_values = vec![1.0];
|
|
|
|
assert!(left.sub(&right).is_none());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stable Parquet field ids for native-histogram sub-fields.
|
|
//
|
|
// External readers resolve nested struct fields by `PARQUET:field_id`, so each
|
|
// sub-field (and list element) needs a stable positive id. The struct schema
|
|
// is fixed (always the same 18 fields). Each histogram column owns a block of
|
|
// ids (offset from a reserved base by the column's id), so several histogram
|
|
// columns in one table get disjoint sub-field ids. The reserved base is
|
|
// disjoint from user column ids and mito2 internal ids (`1 << 30`).
|
|
//
|
|
// The id is computed with checked arithmetic: the reserved base plus
|
|
// `column_id * stride` cannot always fit in a positive `i32` (a `ColumnId` is
|
|
// `u32`), so derivation returns `None` once the representable range is
|
|
// exceeded. Callers must handle `None` explicitly — the SST parquet writer
|
|
// surfaces it as an error rather than wrapping, panicking, or silently
|
|
// dropping the field id.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Reserved base for native-histogram struct sub-field ids.
|
|
pub const NATIVE_HISTOGRAM_SUBFIELD_ID_BASE: i32 = 0x5000_0000;
|
|
|
|
/// Number of ids reserved per histogram column (18 sub-fields + headroom for
|
|
/// list element ids), so multiple histogram columns get disjoint ids.
|
|
pub const NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE: i32 = 64;
|
|
|
|
/// Offset of list element ids within a column's id block.
|
|
pub const NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET: i32 = 32;
|
|
|
|
/// Returns the stable field id for a native-histogram struct sub-field,
|
|
/// namespaced by its parent `column_id`, or `None` if `name` is not a known
|
|
/// sub-field or the derived id overflows a positive `i32`.
|
|
pub fn native_histogram_subfield_id(column_id: i32, name: &str) -> Option<i32> {
|
|
let idx = subfield_index(name)?;
|
|
NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
|
|
.checked_add(column_id.checked_mul(NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE)?)
|
|
.and_then(|v| v.checked_add(idx))
|
|
}
|
|
|
|
/// Returns the stable list `element-id` for a list-typed native-histogram
|
|
/// sub-field, namespaced by its parent `column_id`, or `None` if `name` is not
|
|
/// a known sub-field or the derived id overflows a positive `i32`.
|
|
pub fn native_histogram_list_element_id(column_id: i32, name: &str) -> Option<i32> {
|
|
let idx = subfield_index(name)?;
|
|
NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
|
|
.checked_add(column_id.checked_mul(NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE)?)
|
|
.and_then(|v| v.checked_add(NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET))
|
|
.and_then(|v| v.checked_add(idx))
|
|
}
|
|
|
|
fn subfield_index(name: &str) -> Option<i32> {
|
|
NATIVE_HISTOGRAM_FIELD_NAMES
|
|
.iter()
|
|
.position(|n| *n == name)
|
|
.map(|i| i as i32)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod subfield_id_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn subfield_ids_are_namespaced_and_disjoint() {
|
|
// SCHEMA=0, ZERO_THRESHOLD=1, SUM=2, ..., CUSTOM_VALUES=5.
|
|
let sum_idx = 2;
|
|
let custom_values_idx = 5;
|
|
|
|
// Ids are offset from the reserved base by the parent column id and
|
|
// the sub-field index.
|
|
assert_eq!(
|
|
native_histogram_subfield_id(1, SUM_FIELD),
|
|
Some(NATIVE_HISTOGRAM_SUBFIELD_ID_BASE + NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE + sum_idx,)
|
|
);
|
|
// List element ids additionally carry the list offset.
|
|
assert_eq!(
|
|
native_histogram_list_element_id(1, CUSTOM_VALUES_FIELD),
|
|
Some(
|
|
NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
|
|
+ NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE
|
|
+ NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET
|
|
+ custom_values_idx,
|
|
)
|
|
);
|
|
// Different parent columns get disjoint ids for the same sub-field.
|
|
assert_ne!(
|
|
native_histogram_subfield_id(1, SUM_FIELD),
|
|
native_histogram_subfield_id(7, SUM_FIELD)
|
|
);
|
|
// Unknown sub-field name -> None.
|
|
assert_eq!(native_histogram_subfield_id(1, "not_a_field"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn subfield_ids_overflow_returns_none() {
|
|
// `column_id` is u32-sized, but the derived id must fit in a positive
|
|
// i32. At column_id = 12_582_912, BASE + column_id*64 == i32::MAX + 1,
|
|
// which previously overflowed (debug panic / release wrap). Checked
|
|
// arithmetic must yield None instead of wrapping or panicking.
|
|
assert_eq!(native_histogram_subfield_id(12_582_912, SUM_FIELD), None);
|
|
assert_eq!(
|
|
native_histogram_list_element_id(12_582_912, CUSTOM_VALUES_FIELD),
|
|
None
|
|
);
|
|
// One below that boundary is still representable.
|
|
assert!(native_histogram_subfield_id(12_582_911, SUM_FIELD).is_some());
|
|
}
|
|
}
|