feat: support native histograms in sampled remote read (#9971)

* feat: support native histograms in sampled remote read

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* fix: align native histogram ingestion with remote read validation

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

---------

Signed-off-by: shuiyisong <xixing.sys@gmail.com>
This commit is contained in:
shuiyisong
2026-10-27 03:09:13 +00:00
committed by GitHub
parent 2d00ae25c8
commit 5e9bb3c689
13 changed files with 2270 additions and 106 deletions
+60 -16
View File
@@ -16,8 +16,8 @@ Struct before persistence and are queried only as native histograms.
Prometheus Remote Write 2.0 native histograms and cumulative OTLP/HTTP
exponential histograms are enabled by default.
Remote Write 1.0 histogram payloads are rejected instead of being acknowledged and dropped.
Native-histogram Remote Read is deferred; the existing Remote Read path
continues to return scalar samples only.
Remote Read returns scalar samples and native histograms through `SAMPLES`,
including exact stored integer counts. Streamed responses remain deferred.
# Goals
@@ -25,13 +25,15 @@ continues to return scalar samples only.
2. Keep one stable Prometheus-compatible persisted representation across
Prometheus and OTLP ingestion.
3. Match Prometheus query behavior where it is observable and practical.
4. State intentional limitations explicitly so incomplete behavior is not
4. Return stored native histograms through sampled Remote Read without rounding
integer counts or losing supported histogram fields.
5. State intentional limitations explicitly so incomplete behavior is not
mistaken for support.
# Non-Goals
- Supporting native histograms in Remote Write 1.0.
- Returning native histograms through Prometheus Remote Read.
- Streaming Remote Read responses (`STREAMED_XOR_CHUNKS`).
- Persisting Remote Write metric metadata.
- Persisting exemplars.
- Converting OTLP delta values to cumulative values during ingestion. Supported
@@ -107,19 +109,57 @@ beyond it. The overflow index is `(1024 << schema) + 1` for non-negative schemas
and `(1024 >> -schema) + 1` for negative schemas. Inputs beyond that index are
rejected rather than clamped or merged into infinity.
Ingestion and sampled Remote Read share histogram layout validation. An initial
exponential span offset of `i32::MIN` is rejected on either side, including for
empty spans; other negative initial offsets remain supported. For custom
histograms with `N` bounds, bucket index `N` is the overflow bucket, and empty
spans may end at `N + 1`. Span extents beyond that boundary are rejected.
This tightens ingestion acceptance: these layouts were previously accepted on
write but rejected by Remote Read. A validation failure rejects the entire
Remote Write 2.0 request with HTTP 400 and all written-count headers set to zero,
before any samples or histograms are persisted. Healthy samples in the same
request are also rejected; senders that do not retry HTTP 400 can drop them.
Exemplars and declared metric metadata are accepted on the wire but are not
persisted by this feature.
## Remote Read
Native-histogram Remote Read is deferred. GreptimeDB's existing Remote Read path
continues to negotiate `SAMPLES` responses and serialize scalar samples only.
`/v1/prometheus/read` returns scalar samples and integer or float native
histograms through the existing Snappy-compressed `SAMPLES` protobuf response.
Omitting accepted types, requesting `SAMPLES`, or requesting
`[STREAMED_XOR_CHUNKS, SAMPLES]` selects this format. Streaming-only requests
remain unsupported; the response is still buffered as a whole.
A follow-up can map the stored Struct into `TimeSeries.histograms`. It must
preserve integer counts without passing through `f64`, retain the stored
histogram fields, and decide sampled versus streamed response coverage. The
sampled protobuf has no start-timestamp field, so that omission must remain
explicit if sampled responses are implemented.
One request can query both scalar and histogram metrics. A histogram series
can contain integer and float histogram samples, but the one-sample-kind-per-
metric-table rule remains unchanged. The selected field must be `Float64` or
the canonical native-histogram Struct, regardless of its stored column name.
Integer counts remain exact within the existing nonnegative `i64` storage
range, including above `2^53`. Stored absolute integer buckets become signed
wire deltas, retaining their sequence across span boundaries. Float bucket
counts remain absolute. Schema, spans, custom bounds, zero threshold, sum
(including stale-NaN bits), reset hint, and millisecond timestamps are retained.
The sampled protobuf has no `start_timestamp` field, so that value remains in
storage but is omitted from the response. Raw-delta OTLP histograms retain
their temporality labels and gauge hints; Remote Read does not accumulate them
or reconstruct the original OTLP point.
Malformed or wire-incompatible non-null stored histograms fail the entire
`ReadRequest` with HTTP 500, including healthy queries in that request. Read
validation remains necessary for older rows and typed inserts that bypass the
histogram ingestion encoder. Incompatible stored rows are not repaired by the
stricter ingestion checks. Null histograms remain absent samples.
Custom-bucket interoperability is exercised against Prometheus revision
`e471c7708c6b0b13e2050222bbd5a907b36f4113`, including `AtHistogram` and
`AtFloatHistogram` on integer custom buckets. This is a tested revision, not
a minimum-release claim; older readers can lose custom bounds during integer-
to-float conversion. Integer payloads are not downgraded for those readers.
The [wire fixture and iterator check](../../tests/compatibility/wire/native_histogram_remote_read/README.md)
record the independent expectations and reproduction commands.
## OTLP
@@ -340,16 +380,20 @@ partial-success behavior, kind exclusivity,
stale-marker selector semantics, synthetic-zero rates, incompatible empty
layouts, overflow-safe averages, layout-sensitive equality, infinite custom
midpoints, and exponential overflow indices for schemas `-4`, `0`, and `8`.
Behavioral coverage also verifies frontend PromQL warning and info responses.
Behavioral coverage also verifies frontend PromQL warning and info responses,
sampled Remote Read before and after flush in standalone and distributed
setups, exact large integers, stale-marker bits, timestamp units, and OTLP
normalization. Frozen upstream protobuf fixtures guard the wire encoder.
This work does not change GreptimeDB's persisted Struct or protobuf
dependencies. It adds the disabled-by-default public OTLP gate; existing
native-histogram data remains readable.
dependencies. Remote Read uses the existing endpoint and negotiation without
a new configuration option; existing native-histogram data remains readable.
# Future Work
- Native-histogram Remote Read, including exact integer round-trips and streamed
chunks with start timestamps.
- TODO: Add native histogram and float histogram chunks to
`STREAMED_XOR_CHUNKS`, with checksummed framing and bounded buffering. Decide
separately whether to include start-timestamp-bearing chunk encodings.
- Raw-delta OTLP exponential-histogram storage and temporality-aware native
histogram queries, as described above; no delta-to-cumulative conversion.
- Persistent Remote Write metadata and accurate help/unit updates.
+35 -1
View File
@@ -36,7 +36,10 @@ use datafusion::arrow::datatypes::{
use datafusion_common::{DataFusionError, Result as DfResult};
use datatypes::data_type::{ConcreteDataType, DataType};
use datatypes::types::{StructField, StructType};
pub use encoding::{NativeHistogramError, encode_native_histogram, native_histogram_column_schema};
pub use encoding::{
NativeHistogramError, decode_native_histogram_for_remote_read, encode_native_histogram,
native_histogram_column_schema,
};
use once_cell::sync::Lazy;
use crate::prelude::greptime_native_histogram;
@@ -136,6 +139,37 @@ pub fn is_native_histogram_value_schema(name: &str, data_type: &ConcreteDataType
name == greptime_native_histogram() && is_native_histogram_value_type(data_type)
}
#[cfg(any(test, feature = "testing"))]
pub mod testing {
use api::helper::pb_value_to_value_ref;
use api::v1::{Value, value::ValueData};
use datafusion::arrow::array::{AsArray, StructArray};
use datatypes::vectors::Helper;
use crate::native_histogram::{native_histogram_column_schema, native_histogram_value_type};
/// Builds stored arrays through the same protobuf-to-vector path as ingestion.
pub fn histogram_array(values: &[Option<ValueData>]) -> StructArray {
let schema = native_histogram_column_schema().unwrap();
let rows = values
.iter()
.map(|value_data| {
datatypes::value::Value::from(pb_value_to_value_ref(
&Value {
value_data: value_data.clone(),
},
schema.datatype_extension.as_ref(),
))
})
.collect::<Vec<_>>();
Helper::try_from_row_into_vector(&rows, native_histogram_value_type())
.unwrap()
.to_arrow_array()
.as_struct()
.clone()
}
}
/// Schema identifier for native histograms with explicit custom bucket bounds.
pub const CUSTOM_BUCKETS_SCHEMA: i32 = -53;
const MIN_EXPONENTIAL_SCHEMA: i32 = -4;
File diff suppressed because it is too large Load Diff
+22
View File
@@ -850,6 +850,24 @@ mod tests {
use super::*;
#[test]
fn test_native_histogram_remote_read_response_negotiation() {
for accepted in [
vec![],
vec![ResponseType::Samples as i32],
vec![
ResponseType::StreamedXorChunks as i32,
ResponseType::Samples as i32,
],
] {
assert_eq!(
negotiate_response_type(&accepted).unwrap(),
ResponseType::Samples
);
}
assert!(negotiate_response_type(&[ResponseType::StreamedXorChunks as i32]).is_err());
}
#[test]
fn test_remote_read_label_types() {
use datatypes::schema::ColumnSchema;
@@ -864,6 +882,10 @@ mod tests {
(ConcreteDataType::vector_datatype(3), false),
(ConcreteDataType::int64_datatype(), false),
(ConcreteDataType::boolean_datatype(), false),
(
common_query::native_histogram::native_histogram_value_type().clone(),
false,
),
] {
let schema = Schema::new(vec![
ColumnSchema::new(
+1
View File
@@ -155,6 +155,7 @@ catalog = { workspace = true, features = ["testing"] }
client = { workspace = true, features = ["testing"] }
common-base.workspace = true
common-meta = { workspace = true, features = ["testing"] }
common-query = { workspace = true, features = ["testing"] }
common-test-util.workspace = true
criterion = "0.5"
json5 = "0.4"
+331 -11
View File
@@ -33,6 +33,7 @@ use arrow::datatypes::{
};
use common_grpc::precision::Precision;
use common_memory_manager::MemoryGuard;
use common_query::native_histogram;
use common_query::prelude::{greptime_timestamp, greptime_value};
use common_recordbatch::{RecordBatch, RecordBatches};
use common_telemetry::{tracing, warn};
@@ -458,6 +459,7 @@ pub fn recordbatches_to_timeseries(
for ts in &mut timeseries {
ts.samples.sort_unstable_by_key(|s| s.timestamp);
ts.histograms.sort_unstable_by_key(|h| h.timestamp);
}
timeseries
@@ -531,21 +533,26 @@ fn append_recordbatch_to_timeseries(
),
})?;
// TODO: Add native-histogram encoding when Prometheus Remote Read support is prioritized.
// The current path intentionally returns scalar samples only.
let field_column = recordbatch
.column_by_name(value_column_name)
.with_context(|| error::InvalidPromRemoteReadQueryResultSnafu {
msg: format!("missing value column '{value_column_name}' in query result"),
})?;
let field_column = field_column
.as_primitive_opt::<Float64Type>()
.with_context(|| error::InvalidPromRemoteReadQueryResultSnafu {
let scalar_column = field_column.as_primitive_opt::<Float64Type>();
let histogram_column = recordbatch
.schema
.column_schema_by_name(value_column_name)
.filter(|column| native_histogram::is_native_histogram_value_type(&column.data_type))
.and_then(|_| field_column.as_struct_opt());
ensure!(
scalar_column.is_some() || histogram_column.is_some(),
error::InvalidPromRemoteReadQueryResultSnafu {
msg: format!(
"Expect value column of datatype Float64, actual {:?}",
"Expect value column of datatype Float64 or canonical native histogram Struct, actual {:?}",
field_column.data_type()
),
})?;
}
);
let columns = label_columns(&recordbatch, timestamp_column_name, value_column_name)?;
let mut previous_timeseries: Option<usize> = None;
@@ -577,11 +584,25 @@ fn append_recordbatch_to_timeseries(
continue;
}
let value = field_column.value(row);
let timestamp = ts_column.value(row);
let sample = Sample { value, timestamp };
timeseries[timeseries_index].samples.push(sample);
if let Some(column) = scalar_column {
timeseries[timeseries_index].samples.push(Sample {
value: column.value(row),
timestamp,
});
} else if let Some(column) = histogram_column {
let histogram =
native_histogram::decode_native_histogram_for_remote_read(column, row, timestamp)
.map_err(|error| {
error::InvalidPromRemoteReadQueryResultSnafu {
msg: error.to_string(),
}
.build()
})?;
if let Some(histogram) = histogram {
timeseries[timeseries_index].histograms.push(histogram);
}
}
}
Ok(())
@@ -1540,6 +1561,305 @@ mod tests {
);
}
#[test]
fn test_native_histogram_remote_read_wire_fixture() {
use api::greptime_proto::io::prometheus::write::v2::{
BucketSpan, Histogram,
histogram::{Count, ZeroCount},
};
use api::prom_store::remote::{QueryResult, ReadResponse};
use common_query::native_histogram::testing::histogram_array;
use prost::Message;
let histograms = vec![
Histogram {
count: Some(Count::CountInt((1 << 53) + 13)),
zero_count: Some(ZeroCount::ZeroCountInt((1 << 53) + 1)),
sum: 10.0,
schema: 1,
zero_threshold: 0.001,
reset_hint: 2,
start_timestamp: 123,
positive_spans: vec![
BucketSpan {
offset: -2,
length: 2,
},
BucketSpan {
offset: 3,
length: 1,
},
],
positive_deltas: vec![1, 2, -1],
negative_spans: vec![
BucketSpan {
offset: -3,
length: 1,
},
BucketSpan {
offset: 2,
length: 2,
},
],
negative_deltas: vec![1, 2, -1],
..Default::default()
},
Histogram {
count: Some(Count::CountFloat(6.0)),
zero_count: Some(ZeroCount::ZeroCountFloat(0.5)),
sum: 20.0,
schema: 2,
zero_threshold: 0.002,
reset_hint: 3,
positive_spans: vec![BucketSpan {
offset: 3,
length: 2,
}],
positive_counts: vec![2.0, 3.5],
..Default::default()
},
Histogram {
count: Some(Count::CountInt(1)),
sum: 1.0,
schema: -53,
custom_values: vec![1.0, 2.0],
positive_spans: vec![BucketSpan {
offset: 0,
length: 1,
}],
positive_deltas: vec![1],
..Default::default()
},
Histogram {
count: Some(Count::CountFloat(3.5)),
sum: 4.0,
schema: -53,
custom_values: vec![1.0, 2.0],
reset_hint: 3,
positive_spans: vec![BucketSpan {
offset: 0,
length: 2,
}],
positive_counts: vec![1.0, 2.5],
..Default::default()
},
Histogram {
count: Some(Count::CountInt(0)),
..Default::default()
},
Histogram {
count: Some(Count::CountFloat(0.0)),
..Default::default()
},
Histogram {
count: Some(Count::CountInt(8)),
sum: f64::from_bits(0x7ff8_0000_0000_0123),
positive_spans: vec![BucketSpan {
offset: 0,
length: 2,
}],
positive_deltas: vec![3, -1],
..Default::default()
},
Histogram {
count: Some(Count::CountInt(0)),
sum: f64::from_bits(0x7ff0_0000_0000_0002),
..Default::default()
},
Histogram {
count: Some(Count::CountInt(i64::MAX as u64)),
zero_count: Some(ZeroCount::ZeroCountInt(i64::MAX as u64)),
..Default::default()
},
];
let values = histograms
.iter()
.map(|h| Some(native_histogram::encode_native_histogram(h).unwrap()))
.collect::<Vec<_>>();
let array = histogram_array(&values);
let buckets = array
.column_by_name("positive_buckets_i64")
.unwrap()
.as_list::<i32>()
.value(0);
assert_eq!(
buckets
.as_primitive::<arrow::datatypes::Int64Type>()
.values()
.as_ref(),
&[1, 3, 2]
);
let arrow_schema = Arc::new(ArrowSchema::new(vec![
Field::new(
"ts",
ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
false,
),
Field::new("histogram", array.data_type().clone(), true),
]));
let schema = Arc::new(Schema::try_from(arrow_schema.clone()).unwrap());
let batch = DfRecordBatch::try_new(
arrow_schema,
vec![
Arc::new(TimestampMillisecondArray::from(
(1..=9).map(|i| i * 1000).collect::<Vec<_>>(),
)),
Arc::new(array),
],
)
.unwrap();
let batches = RecordBatches::try_new(
schema.clone(),
vec![RecordBatch::from_df_record_batch(schema, batch)],
)
.unwrap();
let response = ReadResponse {
results: vec![QueryResult {
timeseries: recordbatches_to_timeseries(
"native_histogram_remote_read",
"ts",
"histogram",
batches,
)
.unwrap(),
}],
};
let body = snappy_compress(&response.encode_to_vec()).unwrap();
let mut decoded =
ReadResponse::decode(snappy_decompress(&body).unwrap().as_slice()).unwrap();
let mut expected = ReadResponse::decode(
include_bytes!(
"../../../tests/compatibility/wire/native_histogram_remote_read/response.pb"
)
.as_slice(),
)
.unwrap();
let actual_histograms = &mut decoded.results[0].timeseries[0].histograms;
assert_eq!(actual_histograms[0].positive_deltas, vec![1, 2, -1]);
assert_eq!(actual_histograms[0].negative_deltas, vec![1, 2, -1]);
assert_eq!(actual_histograms[6].positive_deltas, vec![3, -1]);
for (actual, expected) in actual_histograms
.iter_mut()
.zip(&mut expected.results[0].timeseries[0].histograms)
{
assert_eq!(actual.sum.to_bits(), expected.sum.to_bits());
actual.sum = 0.0;
expected.sum = 0.0;
}
assert_eq!(decoded, expected);
// The optional output is consumed by the pinned Prometheus iterator check.
if let Some(path) = std::env::var_os("GREPTIME_NATIVE_HISTOGRAM_REMOTE_READ_OUTPUT") {
std::fs::write(path, body).unwrap();
}
}
#[test]
fn test_native_histogram_remote_read_groups_and_sorts_batches() {
use api::greptime_proto::io::prometheus::write::v2::{Histogram, histogram::Count};
use common_query::native_histogram::testing::histogram_array;
let integer = native_histogram::encode_native_histogram(&Histogram {
count: Some(Count::CountInt(0)),
sum: f64::from_bits(0x7ff0_0000_0000_0002),
..Default::default()
})
.unwrap();
let float = native_histogram::encode_native_histogram(&Histogram {
count: Some(Count::CountFloat(0.0)),
..Default::default()
})
.unwrap();
let histograms = histogram_array(&[Some(integer), Some(float.clone()), None, Some(float)]);
let arrow_schema = Arc::new(ArrowSchema::new(vec![
Field::new(
"event_time",
ArrowDataType::Timestamp(TimeUnit::Microsecond, None),
true,
),
Field::new("legacy_histogram", histograms.data_type().clone(), true),
Field::new_dictionary("instance", ArrowDataType::UInt32, ArrowDataType::Utf8, true),
]));
let schema = Arc::new(Schema::try_from(arrow_schema.clone()).unwrap());
let batch = |timestamps: Vec<Option<i64>>, labels: Vec<Option<u32>>| {
RecordBatch::from_df_record_batch(
schema.clone(),
DfRecordBatch::try_new(
arrow_schema.clone(),
vec![
Arc::new(arrow::array::TimestampMicrosecondArray::from(timestamps)),
Arc::new(histograms.clone()),
Arc::new(DictionaryArray::<UInt32Type>::new(
UInt32Array::from(labels),
Arc::new(StringArray::from(vec![Some("b"), Some("a"), None])),
)),
],
)
.unwrap(),
)
};
let batches = vec![
batch(
vec![Some(4000), Some(3000), Some(5000), Some(-1001)],
vec![Some(0), None, Some(0), Some(0)],
),
batch(
vec![Some(2000), Some(1000), Some(6000), None],
vec![Some(0), Some(2), Some(1), Some(0)],
),
];
let series = recordbatches_to_timeseries(
"latency",
"event_time",
"legacy_histogram",
RecordBatches::try_new(schema.clone(), batches).unwrap(),
)
.unwrap();
assert_eq!(series.len(), 3);
assert_eq!(
series[0].labels,
vec![new_label(
METRIC_NAME_LABEL.to_string(),
"latency".to_string()
)]
);
assert_eq!(
series[0]
.histograms
.iter()
.map(|h| h.timestamp)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(series[1].labels[1].value, "a");
assert!(series[1].histograms.is_empty());
assert_eq!(series[2].labels[1].value, "b");
assert_eq!(
series[2]
.histograms
.iter()
.map(|h| h.timestamp)
.collect::<Vec<_>>(),
vec![-2, 2, 4]
);
assert!(matches!(
series[2].histograms[0].count,
Some(api::prom_store::remote::histogram::Count::CountFloat(0.0))
));
for histogram in &series[2].histograms[1..] {
assert!(matches!(
histogram.count,
Some(api::prom_store::remote::histogram::Count::CountInt(0))
));
assert_eq!(histogram.sum.to_bits(), 0x7ff0_0000_0000_0002);
}
assert!(
series
.iter()
.all(|s| s.samples.is_empty()
&& s.labels.iter().all(|l| l.name != "legacy_histogram"))
);
}
#[test]
fn test_recordbatches_to_timeseries() {
let schema = Arc::new(Schema::new(vec![
+56 -2
View File
@@ -16,8 +16,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use api::greptime_proto::io::prometheus::write::v2::{
Request as RemoteWriteV2Request, Sample as RemoteWriteV2Sample,
TimeSeries as RemoteWriteV2TimeSeries,
BucketSpan as RemoteWriteV2BucketSpan, Request as RemoteWriteV2Request,
Sample as RemoteWriteV2Sample, TimeSeries as RemoteWriteV2TimeSeries,
};
use api::prom_store::remote::{
LabelMatcher, Query, QueryResult, ReadRequest, ReadResponse, WriteRequest,
@@ -28,6 +28,7 @@ use async_trait::async_trait;
use axum::Router;
use axum::http::HeaderMap;
use common_query::Output;
use common_query::native_histogram::CUSTOM_BUCKETS_SCHEMA;
use common_query::prelude::{
GREPTIME_PHYSICAL_TABLE, greptime_native_histogram, greptime_timestamp, greptime_value,
};
@@ -317,6 +318,59 @@ async fn test_prometheus_remote_write_v2_convert_error_has_written_headers() {
assert!(write_rx.try_recv().is_err());
}
#[tokio::test]
async fn test_prometheus_remote_write_v2_rejects_incompatible_histogram_before_writes() {
common_telemetry::init_default_ut_logging();
let (read_tx, _read_rx) = mpsc::channel(100);
let (write_tx, mut write_rx) = mpsc::channel(100);
let app = make_test_app_with_write_capture(read_tx, write_tx);
let client = TestClient::new(app).await;
for (schema, offset, message) in [
(0, i32::MIN, "initial span offset i32::MIN"),
(
CUSTOM_BUCKETS_SCHEMA,
2,
"span extent exceeds custom_values",
),
] {
let mut histogram = remote_write_v2::histogram(2000);
histogram.schema = schema;
histogram.positive_spans = vec![RemoteWriteV2BucketSpan { offset, length: 0 }];
let write_request = RemoteWriteV2Request {
symbols: vec![
String::new(),
prom_store::METRIC_NAME_LABEL.to_string(),
"http_requests_total".to_string(),
"http_request_duration_seconds".to_string(),
],
timeseries: vec![
RemoteWriteV2TimeSeries {
labels_refs: vec![1, 2],
samples: vec![RemoteWriteV2Sample {
value: 42.0,
timestamp: 1000,
start_timestamp: 0,
}],
..Default::default()
},
RemoteWriteV2TimeSeries {
labels_refs: vec![1, 3],
histograms: vec![remote_write_v2::histogram(1000), histogram],
..Default::default()
},
],
};
let result = post_remote_write_v2(&client, &write_request).await;
assert_eq!(result.status(), 400);
assert_remote_write_v2_written_headers(&result.headers(), "0");
assert!(result.text().await.contains(message));
assert!(write_rx.try_recv().is_err());
}
}
#[tokio::test]
async fn test_prometheus_remote_write_v2_write_error_has_partial_written_headers() {
common_telemetry::init_default_ut_logging();
+80
View File
@@ -2838,6 +2838,32 @@ WITH(
.remove(0)
.unwrap();
}
let series =
native_histogram_remote_read(&instance, "delta_distribution", ctx.clone()).await;
assert_eq!(series.len(), 1);
assert!(
series[0]
.labels
.iter()
.any(|l| l.name == "otlp_aggregation_temporality" && l.value == "delta")
);
assert_eq!(series[0].histograms.len(), 3);
for (histogram, (timestamp, count)) in
series[0]
.histograms
.iter()
.zip([(60_000, 10), (120_000, 20), (180_000, 15)])
{
assert_eq!(histogram.timestamp, timestamp);
assert_eq!(
histogram.count,
Some(api::prom_store::remote::histogram::Count::CountInt(count))
);
assert_eq!(histogram.positive_deltas, vec![count as i64]);
assert_eq!(histogram.positive_spans[0].offset, 1);
assert_eq!(histogram.sum, count as f64 * 1.5);
assert_eq!(histogram.reset_hint, 3);
}
for (expression, expected) in [
(
"histogram_count(sum_over_time(delta_distribution[3m]))",
@@ -3139,6 +3165,42 @@ WITH(
}
}
async fn native_histogram_remote_read(
instance: &Arc<Instance>,
table: &str,
ctx: Arc<QueryContext>,
) -> Vec<api::prom_store::remote::TimeSeries> {
use api::prom_store::remote::{LabelMatcher, Query, ReadRequest, ReadResponse};
use prost::Message;
use servers::query_handler::PromStoreProtocolHandler;
let response = instance
.read(
ReadRequest {
queries: vec![Query {
start_timestamp_ms: 0,
end_timestamp_ms: 200_000,
matchers: vec![LabelMatcher {
name: "__name__".to_string(),
value: table.to_string(),
r#type: 0,
}],
..Default::default()
}],
..Default::default()
},
ctx,
)
.await
.unwrap();
let body = servers::prom_store::snappy_decompress(&response.body).unwrap();
ReadResponse::decode(body.as_slice())
.unwrap()
.results
.remove(0)
.timeseries
}
#[tokio::test(flavor = "multi_thread")]
async fn test_otlp_delta_exponential_storage_precision_and_replacement() {
use datatypes::arrow::datatypes::Int64Type;
@@ -3194,6 +3256,24 @@ WITH(
.remove(0)
.unwrap();
}
let series =
native_histogram_remote_read(instance, "interval", ctx.clone()).await;
assert_eq!(series.len(), 1);
assert!(series[0].labels.iter().any(|l| l.name == "otlp_aggregation_temporality" && l.value == "delta"));
let histogram = &series[0].histograms[0];
assert_eq!(histogram.timestamp, 60_000);
assert_eq!(
histogram.count,
Some(api::prom_store::remote::histogram::Count::CountInt(count))
);
assert_eq!(
histogram.zero_count,
Some(api::prom_store::remote::histogram::ZeroCount::ZeroCountInt(
count
))
);
assert_eq!(histogram.reset_hint, 3);
assert_eq!(histogram.sum, 0.0);
let output = instance
.do_query(
"SELECT greptime_timestamp,greptime_native_histogram FROM interval",
+323
View File
@@ -778,6 +778,329 @@ mod tests {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_standalone_native_histogram_remote_read() {
let standalone =
GreptimeDbStandaloneBuilder::new("standalone_native_histogram_remote_read")
.build()
.await;
check_native_histogram_remote_read(standalone.fe_instance()).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn test_distributed_native_histogram_remote_read() {
let cluster =
tests::create_distributed_instance("distributed_native_histogram_remote_read").await;
check_native_histogram_remote_read(&cluster.frontend()).await;
}
async fn check_native_histogram_remote_read(instance: &Arc<Instance>) {
use api::greptime_proto::io::prometheus::write::v2::{
Histogram,
histogram::{Count, ZeroCount},
};
use common_query::native_histogram::{
encode_native_histogram, native_histogram_column_schema,
};
use servers::prom_remote_write::v2::test_util as remote_write_v2;
let ctx = Arc::new(QueryContext::with(DEFAULT_CATALOG_NAME, "prometheus"));
instance
.do_query("CREATE DATABASE prometheus", ctx.clone())
.await
.remove(0)
.unwrap();
let server = HttpServerBuilder::new(HttpOptions::default())
.with_prom_handler(instance.clone(), None, true, Default::default(), None)
.build();
let client = TestClient::new(server.build(server.make_app()).unwrap()).await;
let large = (1u64 << 53) + 1;
let histograms = vec![
Histogram {
count: Some(Count::CountInt(6)),
timestamp: 1000,
positive_spans: vec![api::greptime_proto::io::prometheus::write::v2::BucketSpan {
offset: 0,
length: 3,
}],
positive_deltas: vec![1, 2, -1],
sum: 10.0,
..Default::default()
},
Histogram {
count: Some(Count::CountInt(large)),
zero_count: Some(ZeroCount::ZeroCountInt(large)),
timestamp: 2000,
..Default::default()
},
Histogram {
count: Some(Count::CountFloat(6.0)),
zero_count: Some(ZeroCount::ZeroCountFloat(0.5)),
positive_spans: vec![api::greptime_proto::io::prometheus::write::v2::BucketSpan {
offset: 3,
length: 2,
}],
positive_counts: vec![2.0, 3.5],
timestamp: 3000,
..Default::default()
},
Histogram {
count: Some(Count::CountInt(0)),
sum: f64::from_bits(0x7ff0_0000_0000_0002),
timestamp: 4000,
..Default::default()
},
];
let request = remote_write_v2::request_with_labels_and_histograms(
vec![(prom_store::METRIC_NAME_LABEL, "latency"), ("job", "api")],
histograms,
);
let response = client
.post("/v1/prometheus/write?db=prometheus")
.header(
"content-type",
"application/x-protobuf;proto=io.prometheus.write.v2.Request",
)
.header("content-encoding", "snappy")
.body(prom_store::snappy_compress(&request.encode_to_vec()).unwrap())
.send()
.await;
assert_eq!(response.status(), 204, "{}", response.text().await);
let (scalar, _) = to_grpc_row_insert_requests(&WriteRequest {
timeseries: vec![prom_store::mock_timeseries()[0].clone()],
..Default::default()
})
.unwrap();
instance.write(scalar, ctx.clone(), true).await.unwrap();
for flushed in [false, true] {
if flushed {
instance
.do_query("ADMIN FLUSH_TABLE('latency')", ctx.clone())
.await
.remove(0)
.unwrap();
}
for accepted_response_types in [vec![], vec![0], vec![1, 0]] {
let mut query = remote_query("latency");
query.end_timestamp_ms = 4000;
query.matchers.push(LabelMatcher {
name: "job".to_string(),
value: "api".to_string(),
r#type: MatcherType::Eq as i32,
});
let mut empty = query.clone();
empty.start_timestamp_ms = 5000;
empty.end_timestamp_ms = 6000;
let request = ReadRequest {
queries: vec![
remote_query("metric1"),
query,
remote_query("missing_metric"),
empty,
],
accepted_response_types,
};
let response = client
.post("/v1/prometheus/read?db=prometheus")
.body(prom_store::snappy_compress(&request.encode_to_vec()).unwrap())
.send()
.await;
assert_eq!(response.status(), 200, "{}", response.text().await);
assert_eq!(response.headers()["content-type"], "application/x-protobuf");
assert_eq!(response.headers()["content-encoding"], "snappy");
let body = prom_store::snappy_decompress(&response.bytes().await).unwrap();
let response = ReadResponse::decode(body.as_slice()).unwrap();
assert_eq!(response.results.len(), 4);
assert!(!response.results[0].timeseries[0].samples.is_empty());
assert!(response.results[2].timeseries.is_empty());
assert!(response.results[3].timeseries.is_empty());
let series = &response.results[1].timeseries[0];
assert_eq!(
series.labels,
vec![
Label {
name: "__name__".to_string(),
value: "latency".to_string()
},
Label {
name: "job".to_string(),
value: "api".to_string()
}
]
);
assert_eq!(series.histograms.len(), 4);
assert_eq!(series.histograms[0].positive_deltas, vec![1, 2, -1]);
assert_eq!(series.histograms[2].positive_counts, vec![2.0, 3.5]);
assert_eq!(
series.histograms[1].count,
Some(api::prom_store::remote::histogram::Count::CountInt(large))
);
assert_eq!(
series.histograms[1].zero_count,
Some(api::prom_store::remote::histogram::ZeroCount::ZeroCountInt(
large
))
);
assert_eq!(
series.histograms[2].count,
Some(api::prom_store::remote::histogram::Count::CountFloat(6.0))
);
assert_eq!(series.histograms[3].sum.to_bits(), 0x7ff0_0000_0000_0002);
}
}
let response = client
.post("/v1/prometheus/read?db=prometheus")
.body(
prom_store::snappy_compress(
&ReadRequest {
queries: vec![remote_query("latency")],
accepted_response_types: vec![1],
}
.encode_to_vec(),
)
.unwrap(),
)
.send()
.await;
assert!(!response.status().is_success());
assert!(
response
.text()
.await
.contains("does not support any of the requested response types")
);
// Typed inserts supply sub-millisecond pre-epoch times that neither OTLP
// nor millisecond Remote Write can express, and use a legacy field name.
for (table, datatype, timestamp) in [
(
"legacy_us",
ColumnDataType::TimestampMicrosecond,
ValueData::TimestampMicrosecondValue(-1001),
),
(
"legacy_ns",
ColumnDataType::TimestampNanosecond,
ValueData::TimestampNanosecondValue(-1_001_000),
),
] {
let mut histogram_schema = native_histogram_column_schema().unwrap();
histogram_schema.column_name = "payload".to_string();
let histogram = encode_native_histogram(&Histogram {
count: Some(Count::CountInt(large)),
zero_count: Some(ZeroCount::ZeroCountInt(large)),
..Default::default()
})
.unwrap();
instance
.write(
RowInsertRequests {
inserts: vec![RowInsertRequest {
table_name: table.to_string(),
rows: Some(Rows {
schema: vec![
api::v1::ColumnSchema {
column_name: "event_time".to_string(),
datatype: datatype as i32,
semantic_type: SemanticType::Timestamp as i32,
..Default::default()
},
histogram_schema,
],
rows: vec![Row {
values: vec![
Value {
value_data: Some(timestamp),
},
Value {
value_data: Some(histogram),
},
],
}],
}),
}],
},
ctx.clone(),
false,
)
.await
.unwrap();
for flushed in [false, true] {
if flushed {
instance
.do_query(&format!("ADMIN FLUSH_TABLE('{table}')"), ctx.clone())
.await
.remove(0)
.unwrap();
}
let mut query = remote_query(table);
query.start_timestamp_ms = -2;
query.end_timestamp_ms = 0;
let response = post_remote_read(&client, vec![query]).await;
assert_eq!(response.status(), 200, "{}", response.text().await);
let body = prom_store::snappy_decompress(&response.bytes().await).unwrap();
let response = ReadResponse::decode(body.as_slice()).unwrap();
let series = &response.results[0].timeseries[0];
assert_eq!(series.labels.len(), 1);
assert_eq!(series.histograms[0].timestamp, -2);
assert_eq!(
series.histograms[0].count,
Some(api::prom_store::remote::histogram::Count::CountInt(large))
);
}
}
// Bypass ingestion's histogram codec to exercise whole-request failure.
let ValueData::StructValue(mut malformed) =
encode_native_histogram(&Histogram::default()).unwrap()
else {
unreachable!()
};
malformed.items[14].value_data = Some(ValueData::F64Value(0.0));
instance
.write(
RowInsertRequests {
inserts: vec![RowInsertRequest {
table_name: "malformed_histogram".to_string(),
rows: Some(Rows {
schema: vec![
api::v1::ColumnSchema {
column_name: "ts".to_string(),
datatype: ColumnDataType::TimestampMillisecond as i32,
semantic_type: SemanticType::Timestamp as i32,
..Default::default()
},
native_histogram_column_schema().unwrap(),
],
rows: vec![Row {
values: vec![
Value {
value_data: Some(ValueData::TimestampMillisecondValue(
1000,
)),
},
Value {
value_data: Some(ValueData::StructValue(malformed)),
},
],
}],
}),
}],
},
ctx.clone(),
false,
)
.await
.unwrap();
for queries in [
vec![remote_query("metric1"), remote_query("malformed_histogram")],
vec![remote_query("malformed_histogram"), remote_query("metric1")],
] {
let response = post_remote_read(&client, queries).await;
assert_eq!(response.status(), 500);
}
}
fn remote_query(metric: &str) -> Query {
Query {
start_timestamp_ms: 1000,
+140 -5
View File
@@ -4262,6 +4262,27 @@ pub async fn test_prometheus_remote_write_v2_native_histogram(store_type: Storag
],
..Default::default()
});
let large = (1u64 << 53) + 1;
write_request.timeseries[1].histograms.extend([
Histogram {
count: Some(Count::CountInt(large)),
zero_count: Some(ZeroCount::ZeroCountInt(large)),
timestamp: 5000,
..Default::default()
},
Histogram {
count: Some(Count::CountInt(0)),
sum: f64::from_bits(0x7ff0_0000_0000_0002),
timestamp: 6000,
..Default::default()
},
Histogram {
count: Some(Count::CountFloat(0.0)),
sum: f64::from_bits(0x7ff8_0000_0000_0123),
timestamp: 7000,
..Default::default()
},
]);
let serialized_request = write_request.encode_to_vec();
let compressed_request =
prom_store::snappy_compress(&serialized_request).expect("failed to encode snappy");
@@ -4278,7 +4299,7 @@ pub async fn test_prometheus_remote_write_v2_native_histogram(store_type: Storag
.await;
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let headers = res.headers();
assert_remote_write_v2_written_headers_with_histograms(&headers, "1", "2");
assert_remote_write_v2_written_headers_with_histograms(&headers, "1", "5");
assert!(res.text().await.is_empty());
validate_data(
@@ -4289,7 +4310,7 @@ pub async fn test_prometheus_remote_write_v2_native_histogram(store_type: Storag
)
.await;
let histogram_sql = "select greptime_timestamp, greptime_native_histogram, job, instance from remote_write_v2_latency_seconds order by greptime_timestamp;";
let histogram_sql = "select greptime_timestamp, greptime_native_histogram, job, instance from remote_write_v2_latency_seconds where greptime_timestamp < 5000::TIMESTAMP order by greptime_timestamp;";
let histogram_rows = "[[3000,{\"count_f64\":null,\"count_i64\":8,\"custom_values\":[],\"negative_buckets_f64\":[],\"negative_buckets_i64\":[1],\"negative_span_lengths\":[1],\"negative_span_offsets\":[-2],\"positive_buckets_f64\":[],\"positive_buckets_i64\":[1,3,2],\"positive_span_lengths\":[3],\"positive_span_offsets\":[0],\"reset_hint\":2,\"schema\":1,\"start_timestamp\":1500,\"sum\":10.0,\"zero_count_f64\":null,\"zero_count_i64\":1,\"zero_threshold\":0.001},\"api\",\"localhost:9090\"],[4000,{\"count_f64\":6.0,\"count_i64\":null,\"custom_values\":[],\"negative_buckets_f64\":[],\"negative_buckets_i64\":[],\"negative_span_lengths\":[],\"negative_span_offsets\":[],\"positive_buckets_f64\":[2.0,3.5],\"positive_buckets_i64\":[],\"positive_span_lengths\":[2],\"positive_span_offsets\":[3],\"reset_hint\":3,\"schema\":2,\"start_timestamp\":2500,\"sum\":20.0,\"zero_count_f64\":0.5,\"zero_count_i64\":null,\"zero_threshold\":0.002},\"api\",\"localhost:9090\"]]";
validate_data(
"prometheus_remote_write_v2_native_histogram_rows",
@@ -4299,6 +4320,76 @@ pub async fn test_prometheus_remote_write_v2_native_histogram(store_type: Storag
)
.await;
for flushed in [false, true] {
if flushed {
flush_table(&client, "remote_write_v2_latency_seconds").await;
}
for accepted_response_types in [vec![], vec![0], vec![1, 0]] {
let query = |name: &str| Query {
start_timestamp_ms: 0,
end_timestamp_ms: 10_000,
matchers: vec![LabelMatcher {
name: prom_store::METRIC_NAME_LABEL.to_string(),
value: name.to_string(),
r#type: 0,
}],
..Default::default()
};
let request = ReadRequest {
queries: vec![
query("remote_write_v2_sample_seconds"),
query("missing_native_histogram"),
query("remote_write_v2_latency_seconds"),
],
accepted_response_types,
};
let response = client
.post("/v1/prometheus/read")
.body(prom_store::snappy_compress(&request.encode_to_vec()).unwrap())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["content-type"], "application/x-protobuf");
assert_eq!(response.headers()["content-encoding"], "snappy");
let body = prom_store::snappy_decompress(&response.bytes().await).unwrap();
let response = ReadResponse::decode(body.as_slice()).unwrap();
assert_eq!(response.results.len(), 3);
assert_eq!(
response.results[0].timeseries[0].samples,
vec![Sample {
value: 5.0,
timestamp: 2500
}]
);
assert!(response.results[1].timeseries.is_empty());
let series = &response.results[2].timeseries[0];
assert!(series.samples.is_empty());
assert_eq!(
series
.histograms
.iter()
.map(|h| h.timestamp)
.collect::<Vec<_>>(),
vec![3000, 4000, 5000, 6000, 7000]
);
assert_eq!(series.histograms[0].positive_deltas, vec![1, 2, -1]);
assert_eq!(series.histograms[0].negative_deltas, vec![1]);
assert_eq!(series.histograms[1].positive_counts, vec![2.0, 3.5]);
assert_eq!(
series.histograms[2].count,
Some(api::prom_store::remote::histogram::Count::CountInt(large))
);
assert_eq!(
series.histograms[2].zero_count,
Some(api::prom_store::remote::histogram::ZeroCount::ZeroCountInt(
large
))
);
assert_eq!(series.histograms[3].sum.to_bits(), 0x7ff0_0000_0000_0002);
assert_eq!(series.histograms[4].sum.to_bits(), 0x7ff8_0000_0000_0123);
}
}
// The native histogram column is a struct of nested lists, so its SST
// encoding differs from its memtable representation.
flush_table(&client, "remote_write_v2_latency_seconds").await;
@@ -9133,9 +9224,17 @@ pub async fn test_otlp_exponential_histogram(store_type: StorageType) {
)]
};
let (app, mut guard) =
setup_test_http_app_with_frontend(store_type, "test_otlp_exponential_histogram").await;
let client = TestClient::new(app).await;
let standalone = tests_integration::standalone::GreptimeDbStandaloneBuilder::new(
"test_otlp_exponential_histogram",
)
.with_default_store_type(store_type)
.build()
.await;
let server = build_test_prom_server(standalone.fe_instance().clone(), false, None)
.with_otlp_handler(standalone.fe_instance().clone(), true)
.build();
let client = TestClient::new(server.build(server.make_app()).unwrap()).await;
let mut guard = standalone.guard;
let res = send_req(&client, headers(), "/v1/otlp/v1/metrics", body, false).await;
assert_eq!(StatusCode::OK, res.status());
let response = ExportMetricsServiceResponse::decode(res.bytes().await).unwrap();
@@ -9158,6 +9257,42 @@ pub async fn test_otlp_exponential_histogram(store_type: StorageType) {
)
.await;
for flushed in [false, true] {
if flushed {
flush_table(&client, "otlp_exponential_latency").await;
}
let response = post_remote_read(
&client,
vec![Query {
start_timestamp_ms: 0,
end_timestamp_ms: 4000,
matchers: vec![LabelMatcher {
name: "__name__".to_string(),
value: "otlp_exponential_latency".to_string(),
r#type: 0,
}],
..Default::default()
}],
)
.await;
let histogram = &response.results[0].timeseries[0].histograms[0];
assert_eq!(histogram.timestamp, 3000);
assert_eq!(
histogram.count,
Some(api::prom_store::remote::histogram::Count::CountInt(4))
);
assert_eq!(
histogram.zero_count,
Some(api::prom_store::remote::histogram::ZeroCount::ZeroCountInt(
1
))
);
assert_eq!(histogram.positive_deltas, vec![1, 1]);
assert_eq!(histogram.positive_spans[0].offset, 0);
assert_eq!(histogram.reset_hint, 0);
assert_eq!(histogram.sum, 8.0);
}
for query in [
"histogram_count(otlp_exponential_latency)",
"histogram_sum(otlp_exponential_latency)",
+8
View File
@@ -15,6 +15,14 @@ read-preference defaulting and unrelated metadata preservation:
cargo nextest run -p session -E 'test(test_legacy_query_context_wire_defaults_to_leader)'
```
The [native histogram Remote Read fixture](wire/native_histogram_remote_read/README.md)
checks the sampled encoder against independent Prometheus protobuf values,
including exact integer counts, custom bounds, and NaN payloads:
```shell
cargo nextest run -p servers -E 'test(native_histogram_remote_read_wire_fixture)'
```
## Object store WAL fixtures
Frozen objects of the object store WAL are checked byte for byte by the format tests of
@@ -0,0 +1,184 @@
# Native histogram sampled Remote Read wire compatibility
`response.pb` is a frozen `prompb.ReadResponse` generated from Prometheus
revision `e471c7708c6b0b13e2050222bbd5a907b36f4113`, independently of
`greptime-proto`. It contains one query result, one series named
`native_histogram_remote_read`, and nine samples at 1000ms intervals.
| Timestamp (ms) | Independent expectation |
| --- | --- |
| 1000 | Integer count `2^53 + 13`, zero count `2^53 + 1`, both bucket sides `[1,3,2]`, wire deltas `[1,2,-1]` across span boundaries, schema 1, reset hint 2 |
| 2000 | Float count 6, zero count 0.5, absolute buckets `[2,3.5]`, schema 2, gauge hint |
| 3000 | Integer custom buckets `[1,2]`, one observation in `(-Inf,1]` |
| 4000 | Float custom buckets `[1,2]`, absolute counts `[1,2.5]`, gauge hint |
| 5000 | Empty integer histogram with explicit count and zero-count oneofs |
| 6000 | Empty float histogram with explicit count and zero-count oneofs |
| 7000 | Integer count 8, buckets `[3,2]`, wire deltas `[3,-1]`, ordinary NaN sum bits `0x7ff8000000000123` |
| 8000 | Empty integer histogram, stale NaN sum bits `0x7ff0000000000002` |
| 9000 | Integer count and zero count `i64::MAX` |
The 420-byte fixture has SHA-256 `f1414bfa6f0821e920a88e325964abfad0269b05b7c0643a84ca6e908729d1eb`.
Generation used Go `go1.27.3 darwin/arm64`, the pinned Prometheus `go.mod` and
`go.sum` (declared Go version `1.26.10`), and its generated `prompb` types.
Direct wire dependencies were `github.com/gogo/protobuf v1.3.2` and
`github.com/golang/snappy v1.0.0`. No local `protoc` or Greptime protobuf
generator is involved.
## Automated regression
From the GreptimeDB checkout:
```shell
cargo nextest run -p servers -E 'test(native_histogram_remote_read_wire_fixture)'
```
This constructs stored arrays from independent Rust inputs, checks fixed
absolute bucket counts, runs the actual record-batch encoder, Snappy-compresses
and protobuf-decodes its output, and compares all fields with the upstream
fixture. NaN payloads are compared by bits. It also checks fixed wire deltas;
converting those deltas back with an upstream helper is not the expected-value
generator. Tests never regenerate the frozen fixture.
## Reproduce and check with Prometheus
Use a scratch module to preserve the Prometheus checkout. Set the two checkout
paths, create a scratch directory, and save the Go program below as `main.go`
in that directory:
```shell
PROMETHEUS_CHECKOUT=/path/to/prometheus
GREPTIMEDB_CHECKOUT=/path/to/greptimedb
NH_SCRATCH=$(mktemp -d)
git -C "$PROMETHEUS_CHECKOUT" rev-parse HEAD
cp "$PROMETHEUS_CHECKOUT/go.mod" "$PROMETHEUS_CHECKOUT/go.sum" "$NH_SCRATCH/"
cd "$NH_SCRATCH"
go mod edit -module=native-histogram-interop
go mod edit -require=github.com/prometheus/prometheus@v0.0.0
go mod edit -replace="github.com/prometheus/prometheus=$PROMETHEUS_CHECKOUT"
# Save the Go program below as main.go here before running these commands.
go run -mod=mod . generate "$NH_SCRATCH/response.pb"
shasum -a 256 "$NH_SCRATCH/response.pb"
cmp "$NH_SCRATCH/response.pb" "$GREPTIMEDB_CHECKOUT/tests/compatibility/wire/native_histogram_remote_read/response.pb"
cd "$GREPTIMEDB_CHECKOUT"
GREPTIME_NATIVE_HISTOGRAM_REMOTE_READ_OUTPUT="$NH_SCRATCH/greptime-response.snappy" \
cargo nextest run -p servers -E 'test(native_histogram_remote_read_wire_fixture)'
cd "$NH_SCRATCH"
go run -mod=mod . check "$NH_SCRATCH/greptime-response.snappy"
```
The check consumes `storage/remote.FromQueryResult` iterators to exhaustion,
checks both iterator and series-set errors, integer/float types, exact integer
counts, timestamps, spans, bucket placement, custom bounds, reset hints, and
NaN bits. It calls both `AtHistogram` and `AtFloatHistogram` on integer custom
samples, including a single finite-bound bucket. Intentional integer-to-float
conversion is not expected to retain large-integer precision.
Verified on 2026-10-26 against the response emitted by the Rust test:
`PASS: 9 histogram samples, both iterator accessors, no series or iterator errors`.
The tested baseline is the exact revision above, not a claimed minimum
Prometheus release. This Go check is a manual implementation check; it is not
part of the existing Go CI. The Rust regression is the automated guard.
```go
package main
import (
"fmt"
"math"
"os"
"reflect"
"github.com/golang/snappy"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage/remote"
"github.com/prometheus/prometheus/tsdb/chunkenc"
)
func expected() *prompb.ReadResponse {
hs := []prompb.Histogram{
{Count: &prompb.Histogram_CountInt{CountInt: (1<<53)+13}, ZeroCount: &prompb.Histogram_ZeroCountInt{ZeroCountInt: (1<<53)+1}, Sum: 10, Schema: 1, ZeroThreshold: 0.001, ResetHint: prompb.Histogram_NO,
PositiveSpans: []prompb.BucketSpan{{Offset: -2, Length: 2}, {Offset: 3, Length: 1}}, PositiveDeltas: []int64{1,2,-1}, NegativeSpans: []prompb.BucketSpan{{Offset: -3, Length: 1}, {Offset: 2, Length: 2}}, NegativeDeltas: []int64{1,2,-1}},
{Count: &prompb.Histogram_CountFloat{CountFloat: 6}, ZeroCount: &prompb.Histogram_ZeroCountFloat{ZeroCountFloat: 0.5}, Sum: 20, Schema: 2, ZeroThreshold: 0.002, ResetHint: prompb.Histogram_GAUGE,
PositiveSpans: []prompb.BucketSpan{{Offset: 3, Length: 2}}, PositiveCounts: []float64{2,3.5}},
{Count: &prompb.Histogram_CountInt{CountInt: 1}, ZeroCount: &prompb.Histogram_ZeroCountInt{}, Sum: 1, Schema: -53, CustomValues: []float64{1,2}, PositiveSpans: []prompb.BucketSpan{{Length: 1}}, PositiveDeltas: []int64{1}},
{Count: &prompb.Histogram_CountFloat{CountFloat: 3.5}, ZeroCount: &prompb.Histogram_ZeroCountFloat{}, Sum: 4, Schema: -53, CustomValues: []float64{1,2}, PositiveSpans: []prompb.BucketSpan{{Length: 2}}, PositiveCounts: []float64{1,2.5}, ResetHint: prompb.Histogram_GAUGE},
{Count: &prompb.Histogram_CountInt{}, ZeroCount: &prompb.Histogram_ZeroCountInt{}},
{Count: &prompb.Histogram_CountFloat{}, ZeroCount: &prompb.Histogram_ZeroCountFloat{}},
{Count: &prompb.Histogram_CountInt{CountInt: 8}, ZeroCount: &prompb.Histogram_ZeroCountInt{}, Sum: math.Float64frombits(0x7ff8000000000123), PositiveSpans: []prompb.BucketSpan{{Length: 2}}, PositiveDeltas: []int64{3,-1}},
{Count: &prompb.Histogram_CountInt{}, ZeroCount: &prompb.Histogram_ZeroCountInt{}, Sum: math.Float64frombits(0x7ff0000000000002)},
{Count: &prompb.Histogram_CountInt{CountInt: math.MaxInt64}, ZeroCount: &prompb.Histogram_ZeroCountInt{ZeroCountInt: math.MaxInt64}},
}
for i := range hs { hs[i].Timestamp = int64(i+1)*1000 }
return &prompb.ReadResponse{Results: []*prompb.QueryResult{{Timeseries: []*prompb.TimeSeries{{Labels: []prompb.Label{{Name:"__name__", Value:"native_histogram_remote_read"}}, Histograms:hs}}}}}
}
func check(ok bool, args ...any) { if !ok { panic(fmt.Sprint(args...)) } }
func main() {
want := expected()
if os.Args[1] == "generate" {
data, err := want.Marshal(); if err != nil { panic(err) }
if err := os.WriteFile(os.Args[2], data, 0644); err != nil { panic(err) }
fmt.Printf("generated %d bytes\n", len(data)); return
}
data, err := os.ReadFile(os.Args[2]); if err != nil { panic(err) }
data, err = snappy.Decode(nil, data); if err != nil { panic(err) }
var got prompb.ReadResponse
if err = got.Unmarshal(data); err != nil { panic(err) }
check(len(got.Results)==1 && len(got.Results[0].Timeseries)==1, "result/series count")
hs := got.Results[0].Timeseries[0].Histograms
expectedHs := want.Results[0].Timeseries[0].Histograms
check(len(hs)==len(expectedHs), "histogram count")
for i := range hs {
g,w := hs[i], expectedHs[i]
check(math.Float64bits(g.Sum)==math.Float64bits(w.Sum), "sum bits ", i)
g.Sum,w.Sum=0,0
check(reflect.DeepEqual(g,w), "protobuf fields ", i, ": ", g, " expected ", w)
}
ss := remote.FromQueryResult(true, got.Results[0])
samples,series := 0,0
for ss.Next() {
series++
it := ss.At().Iterator(nil)
for typ := it.Next(); typ != chunkenc.ValNone; typ=it.Next() {
w := expectedHs[samples]
if w.IsFloatHistogram() {
check(typ==chunkenc.ValFloatHistogram, "float type ", samples)
t,h:=it.AtFloatHistogram(nil)
check(t==w.Timestamp && h.Count==w.GetCountFloat() && h.ZeroCount==w.GetZeroCountFloat(), "float count/time ", samples)
check(reflect.DeepEqual(h.PositiveBuckets,w.PositiveCounts), "float buckets ", samples)
check(reflect.DeepEqual(h.CustomValues,w.CustomValues), "float bounds ", samples)
check(math.Float64bits(h.Sum)==math.Float64bits(w.Sum), "float sum ", samples)
check(int32(h.CounterResetHint)==int32(w.ResetHint), "float reset ", samples)
} else {
check(typ==chunkenc.ValHistogram, "integer type ", samples)
t,h:=it.AtHistogram(nil)
check(t==w.Timestamp && h.Count==w.GetCountInt() && h.ZeroCount==w.GetZeroCountInt(), "integer count/time ", samples)
check(reflect.DeepEqual(h.PositiveBuckets,w.PositiveDeltas) && reflect.DeepEqual(h.NegativeBuckets,w.NegativeDeltas), "integer deltas ", samples)
check(reflect.DeepEqual(h.CustomValues,w.CustomValues), "integer bounds ", samples)
check(math.Float64bits(h.Sum)==math.Float64bits(w.Sum), "integer sum ", samples)
check(int32(h.CounterResetHint)==int32(w.ResetHint), "integer reset ", samples)
_,f:=it.AtFloatHistogram(nil)
check(reflect.DeepEqual(f.CustomValues,w.CustomValues), "converted bounds ", samples)
if w.Schema == -53 {
buckets:=f.PositiveBucketIterator()
check(buckets.Next(), "custom bucket missing")
b:=buckets.At()
check(b.Lower==math.Inf(-1) && b.Upper==1 && b.Count==1, "custom bucket placement: ",b)
check(!buckets.Next(), "extra custom bucket")
}
}
_,f:=it.AtFloatHistogram(nil)
check(f.Schema==w.Schema && f.ZeroThreshold==w.ZeroThreshold, "schema/threshold ",samples)
check(len(f.PositiveSpans)==len(w.PositiveSpans) && len(f.NegativeSpans)==len(w.NegativeSpans), "span counts ",samples)
for i,s:=range f.PositiveSpans { check(s.Offset==w.PositiveSpans[i].Offset && s.Length==w.PositiveSpans[i].Length,"positive spans") }
for i,s:=range f.NegativeSpans { check(s.Offset==w.NegativeSpans[i].Offset && s.Length==w.NegativeSpans[i].Length,"negative spans") }
samples++
}
check(it.Err()==nil,"iterator error: ",it.Err())
}
check(ss.Err()==nil,"series error: ",ss.Err())
check(series==1 && samples==9,"series/sample count ",series,"/",samples)
fmt.Println("PASS: 9 histogram samples, both iterator accessors, no series or iterator errors")
}
```