Files
greptimedb/src/common/query/src/native_histogram.rs
T

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());
}
}