Skip to content

Commit 6f0eb2b

Browse files
add series hash in metrics ingestion, bounded streaming merge
1 parent 1452b4a commit 6f0eb2b

4 files changed

Lines changed: 200 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ thiserror = "2.0"
181181
ulid = { version = "1.0", features = ["serde"] }
182182
uuid = { version = "1", features = ["v4"] }
183183
xxhash-rust = { version = "0.8", features = ["xxh3"] }
184+
rustc-hash = "2"
184185
futures-core = "0.3.31"
185186
tempfile = "3.20.0"
186187
lazy_static = "1.4.0"

src/otel/metrics.rs

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use opentelemetry_proto::tonic::metrics::v1::{
2323
};
2424
use serde_json::{Map, Value};
2525

26+
use rustc_hash::FxHasher;
27+
use std::hash::Hasher;
2628
use tracing::info_span;
2729

2830
use crate::metrics::increment_metrics_collected_by_date;
@@ -31,7 +33,7 @@ use super::otel_utils::{
3133
convert_epoch_nano_to_timestamp, insert_attributes, insert_number_if_some,
3234
};
3335

34-
pub const OTEL_METRICS_KNOWN_FIELD_LIST: [&str; 36] = [
36+
pub const OTEL_METRICS_KNOWN_FIELD_LIST: [&str; 37] = [
3537
"metric_name",
3638
"metric_description",
3739
"metric_unit",
@@ -68,8 +70,54 @@ pub const OTEL_METRICS_KNOWN_FIELD_LIST: [&str; 36] = [
6870
"scope_dropped_attributes_count",
6971
"resource_dropped_attributes_count",
7072
"resource_schema_url",
73+
// Precomputed per-sample identity of the physical series. Stable hash
74+
// of `metric_name` + sorted attribute key/value pairs. Lets the query
75+
// layer group samples into physical series via a single u64 column
76+
// read instead of decoding every label column and hashing per row.
77+
"_series_hash",
7178
];
7279

80+
/// Compute a stable u64 identifier for the physical series a sample
81+
/// belongs to. Hashes `metric_name` plus every attribute key/value pair
82+
/// that survived OTel flattening — everything in the flattened data
83+
/// point that isn't a known sample-level field is treated as a label.
84+
///
85+
/// Hash output must be stable across process restarts and matching at
86+
/// query time. Uses rustc-hash's FxHasher (fast, deterministic,
87+
/// non-cryptographic) and feeds keys in sorted order so the hash
88+
/// doesn't depend on JSON Map iteration order.
89+
fn compute_series_hash(dp: &Map<String, Value>) -> u64 {
90+
let known: std::collections::HashSet<&str> =
91+
OTEL_METRICS_KNOWN_FIELD_LIST.iter().copied().collect();
92+
let mut label_pairs: Vec<(&str, String)> = dp
93+
.iter()
94+
.filter(|(k, _)| !known.contains(k.as_str()))
95+
.map(|(k, v)| {
96+
let v_str = match v {
97+
Value::String(s) => s.clone(),
98+
other => other.to_string(),
99+
};
100+
(k.as_str(), v_str)
101+
})
102+
.collect();
103+
label_pairs.sort_by(|a, b| a.0.cmp(b.0));
104+
105+
let mut hasher = FxHasher::default();
106+
// Include metric_name in the identity. Without it, two different
107+
// metrics with identical label sets would collide into one series.
108+
if let Some(Value::String(name)) = dp.get("metric_name") {
109+
hasher.write(name.as_bytes());
110+
hasher.write(b"\0");
111+
}
112+
for (k, v) in &label_pairs {
113+
hasher.write(k.as_bytes());
114+
hasher.write(b"=");
115+
hasher.write(v.as_bytes());
116+
hasher.write(b"\0");
117+
}
118+
hasher.finish()
119+
}
120+
73121
/// otel metrics event has json array for exemplar
74122
/// this function flatten the exemplar json array
75123
/// and returns a `Map` of the exemplar json
@@ -564,6 +612,16 @@ fn process_resource_metrics<T, S, M>(
564612
for (k, v) in &envelope {
565613
dp.insert(k.clone(), v.clone());
566614
}
615+
// Compute the physical-series hash AFTER envelope merge
616+
// so resource/scope attributes participate in series
617+
// identity (they're labels from the query layer's
618+
// perspective). Computed once per data point — O(label
619+
// count) per sample, ~200 ns at typical attribute counts.
620+
let series_hash = compute_series_hash(&dp);
621+
dp.insert(
622+
"_series_hash".to_string(),
623+
Value::Number(series_hash.into()),
624+
);
567625
vec_otel_json.push(Value::Object(dp));
568626
}
569627
}
@@ -655,3 +713,110 @@ fn flatten_data_point_flags(flags: u32) -> Map<String, Value> {
655713
);
656714
data_point_flags_json
657715
}
716+
717+
#[cfg(test)]
718+
mod tests {
719+
use super::*;
720+
721+
fn make_dp() -> Map<String, Value> {
722+
let mut dp = Map::new();
723+
dp.insert(
724+
"metric_name".to_string(),
725+
Value::String("counter.app.metric_0006".into()),
726+
);
727+
dp.insert(
728+
"time_unix_nano".to_string(),
729+
Value::String("2026-05-19T09:00:00Z".into()),
730+
);
731+
dp.insert("data_point_value".to_string(), Value::Number(1000.into()));
732+
dp.insert("is_monotonic".to_string(), Value::Bool(true));
733+
dp.insert("service.name".to_string(), Value::String("api".into()));
734+
dp.insert("http.method".to_string(), Value::String("GET".into()));
735+
dp.insert("request.id".to_string(), Value::String("req-1".into()));
736+
dp
737+
}
738+
739+
#[test]
740+
fn series_hash_stable_across_runs() {
741+
// Same input → same hash. Locks in the wire contract between
742+
// ingest and query layers; any algorithm change here breaks
743+
// grouping for already-ingested data.
744+
let dp = make_dp();
745+
let h1 = compute_series_hash(&dp);
746+
let h2 = compute_series_hash(&dp);
747+
assert_eq!(h1, h2);
748+
}
749+
750+
#[test]
751+
fn series_hash_independent_of_label_insertion_order() {
752+
// serde_json::Map preserves insertion order; query side may see
753+
// labels in different order. Hash must be insertion-order-agnostic.
754+
let mut a = Map::new();
755+
a.insert("metric_name".to_string(), Value::String("m".into()));
756+
a.insert("service.name".to_string(), Value::String("api".into()));
757+
a.insert("http.method".to_string(), Value::String("GET".into()));
758+
759+
let mut b = Map::new();
760+
b.insert("http.method".to_string(), Value::String("GET".into()));
761+
b.insert("metric_name".to_string(), Value::String("m".into()));
762+
b.insert("service.name".to_string(), Value::String("api".into()));
763+
764+
assert_eq!(compute_series_hash(&a), compute_series_hash(&b));
765+
}
766+
767+
#[test]
768+
fn series_hash_changes_with_label_value() {
769+
let dp = make_dp();
770+
let mut dp2 = dp.clone();
771+
dp2.insert("service.name".to_string(), Value::String("billing".into()));
772+
assert_ne!(compute_series_hash(&dp), compute_series_hash(&dp2));
773+
}
774+
775+
#[test]
776+
fn series_hash_changes_with_metric_name() {
777+
// Two different metrics with identical labels must hash to
778+
// different values, otherwise samples for `requests_total` and
779+
// `latency_seconds` would collide into one logical series.
780+
let dp = make_dp();
781+
let mut dp2 = dp.clone();
782+
dp2.insert(
783+
"metric_name".to_string(),
784+
Value::String("other.metric".into()),
785+
);
786+
assert_ne!(compute_series_hash(&dp), compute_series_hash(&dp2));
787+
}
788+
789+
#[test]
790+
fn series_hash_ignores_sample_level_fields() {
791+
// time_unix_nano and data_point_value belong to the SAMPLE, not
792+
// the series. Hash must be identical across samples of the same
793+
// physical series taken at different times with different values.
794+
let dp = make_dp();
795+
let mut dp_later = dp.clone();
796+
dp_later.insert(
797+
"time_unix_nano".to_string(),
798+
Value::String("2026-05-19T10:00:00Z".into()),
799+
);
800+
dp_later.insert("data_point_value".to_string(), Value::Number(2000.into()));
801+
assert_eq!(compute_series_hash(&dp), compute_series_hash(&dp_later));
802+
}
803+
804+
#[test]
805+
fn series_hash_distinguishes_label_kv_swap() {
806+
// Pathological pair: {a=bc, d=e} vs {a=b, cd=e}. A naive
807+
// concatenation hash would emit identical bytes. Delimiters in
808+
// the hasher input prevent this — verify here so a future
809+
// optimisation can't silently regress collision resistance.
810+
let mut a = Map::new();
811+
a.insert("metric_name".to_string(), Value::String("m".into()));
812+
a.insert("a".to_string(), Value::String("bc".into()));
813+
a.insert("d".to_string(), Value::String("e".into()));
814+
815+
let mut b = Map::new();
816+
b.insert("metric_name".to_string(), Value::String("m".into()));
817+
b.insert("a".to_string(), Value::String("b".into()));
818+
b.insert("cd".to_string(), Value::String("e".into()));
819+
820+
assert_ne!(compute_series_hash(&a), compute_series_hash(&b));
821+
}
822+
}

src/query/mod.rs

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ use datafusion::sql::parser::DFParser;
4343
use datafusion::sql::resolve::resolve_table_references;
4444
use datafusion::sql::sqlparser::dialect::PostgreSqlDialect;
4545
use futures::Stream;
46-
use futures::stream::select_all;
4746
use itertools::Itertools;
4847
use once_cell::sync::Lazy;
4948
use serde::{Deserialize, Serialize};
@@ -55,6 +54,8 @@ use std::sync::{Arc, RwLock};
5554
use std::task::{Context, Poll};
5655
use sysinfo::System;
5756
use tokio::runtime::Runtime;
57+
use tokio_stream::wrappers::ReceiverStream;
58+
use tracing::Instrument;
5859

5960
use self::error::ExecuteError;
6061
use self::stream_schema_provider::GlobalSchemaProvider;
@@ -74,21 +75,7 @@ use crate::storage::{ObjectStorageProvider, ObjectStoreFormat};
7475
use crate::utils::time::TimeRange;
7576

7677
/// Boxed record-batch stream used as the streaming half of query results.
77-
type BoxedBatchStream = Pin<
78-
Box<
79-
RecordBatchStreamAdapter<
80-
select_all::SelectAll<
81-
Pin<
82-
Box<
83-
dyn RecordBatchStream<
84-
Item = Result<RecordBatch, datafusion::error::DataFusionError>,
85-
> + Send,
86-
>,
87-
>,
88-
>,
89-
>,
90-
>,
91-
>;
78+
type BoxedBatchStream = SendableRecordBatchStream;
9279

9380
/// Result type returned by query execution: either collected batches or a streaming adapter, plus field names.
9481
type QueryResult = Result<(Either<Vec<RecordBatch>, BoxedBatchStream>, Vec<String>), ExecuteError>;
@@ -320,19 +307,36 @@ impl Query {
320307
active_streams: AtomicUsize::new(output_partitions),
321308
});
322309

323-
let streams = execute_stream_partitioned(plan.clone(), task_ctx.clone())?
324-
.into_iter()
325-
.map(|s| {
326-
let wrapped =
327-
PartitionedMetricMonitor::new(s, monitor_state.clone(), tenant_id.clone());
328-
Box::pin(wrapped) as SendableRecordBatchStream
329-
})
330-
.collect_vec();
331-
332-
let merged_stream = futures::stream::select_all(streams);
310+
let partition_streams = execute_stream_partitioned(plan.clone(), task_ctx.clone())?;
311+
let n = partition_streams.len();
312+
// Bound channel so a slow consumer backpressures producers — caps peak memory.
313+
let (tx, rx) = tokio::sync::mpsc::channel::<
314+
Result<RecordBatch, datafusion::error::DataFusionError>,
315+
>((num_cpus::get() * 4).max(n * 2).max(1));
316+
317+
for s in partition_streams {
318+
let wrapped =
319+
PartitionedMetricMonitor::new(s, monitor_state.clone(), tenant_id.clone());
320+
let tx = tx.clone();
321+
let span = tracing::Span::current();
322+
tokio::spawn(
323+
async move {
324+
let mut stream: SendableRecordBatchStream = Box::pin(wrapped);
325+
use futures::StreamExt;
326+
while let Some(batch) = stream.next().await {
327+
if tx.send(batch).await.is_err() {
328+
break;
329+
}
330+
}
331+
}
332+
.instrument(span),
333+
);
334+
}
335+
drop(tx);
333336

334-
let final_stream = RecordBatchStreamAdapter::new(plan.schema(), merged_stream);
335-
Either::Right(Box::pin(final_stream))
337+
let merged = ReceiverStream::new(rx);
338+
let final_stream = RecordBatchStreamAdapter::new(plan.schema(), merged);
339+
Either::Right(Box::pin(final_stream) as SendableRecordBatchStream)
336340
};
337341

338342
Ok((results, fields))

0 commit comments

Comments
 (0)