Skip to content

Commit b67b828

Browse files
nikhilsinhaparseableparmesant
authored andcommitted
feat: add series hash in metrics ingestion, bounded streaming merge (parseablehq#1648)
add series hash in metrics ingestion, bounded streaming merge and sort by metric name for otel-metrics stream
1 parent 44dcb24 commit b67b828

5 files changed

Lines changed: 323 additions & 36 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
@@ -182,6 +182,7 @@ thiserror = "2.0"
182182
ulid = { version = "1.0", features = ["serde"] }
183183
uuid = { version = "1", features = ["v4"] }
184184
xxhash-rust = { version = "0.8", features = ["xxh3"] }
185+
rustc-hash = "2"
185186
futures-core = "0.3.31"
186187
tempfile = "3.20.0"
187188
lazy_static = "1.4.0"

src/otel/metrics.rs

Lines changed: 173 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,58 @@ 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
74+
// u64 hash of `metric_name` + sorted attribute key/value pairs,
75+
// stored as a decimal-encoded string so arrow-json infers Utf8 and
76+
// we get byte-exact roundtrip. Int64/Float64 inference dropped bits
77+
// for hashes near the high range; string sidesteps that entirely.
78+
// Lets the query layer group samples into physical series via a
79+
// single column read instead of decoding every label column and
80+
// hashing per row.
81+
"__series_hash",
7182
];
7283

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

src/parseable/streams.rs

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*
1818
*/
1919

20-
use arrow_array::RecordBatch;
20+
use arrow_array::{ArrayRef, RecordBatch};
2121
use arrow_ipc::reader::StreamReader;
2222
use arrow_schema::{Field, Fields, Schema};
2323
use chrono::{NaiveDateTime, Timelike, Utc};
@@ -589,12 +589,26 @@ impl Stream {
589589
Encoding::DELTA_BINARY_PACKED,
590590
);
591591

592-
// Create sorting columns
593-
let mut sorting_column_vec = vec![SortingColumn {
592+
// Build sorting columns. For OTel-metrics streams, put
593+
// `metric_name` ahead of the time partition so per-page parquet
594+
// min/max stats can prune by metric (PromQL's universal selector
595+
// predicate). The actual row order is enforced at write time
596+
// (`sort_batch_for_metric_pruning`) — this just advertises the
597+
// sort in parquet footer metadata so readers can rely on it.
598+
let is_otel_metrics = self.is_otel_metrics();
599+
let mut sorting_column_vec: Vec<SortingColumn> = Vec::new();
600+
if is_otel_metrics && let Ok(name_idx) = merged_schema.index_of("metric_name") {
601+
sorting_column_vec.push(SortingColumn {
602+
column_idx: name_idx as i32,
603+
descending: false,
604+
nulls_first: false,
605+
});
606+
}
607+
sorting_column_vec.push(SortingColumn {
594608
column_idx: time_partition_idx as i32,
595609
descending: true,
596-
nulls_first: true,
597-
}];
610+
nulls_first: false,
611+
});
598612

599613
// Describe custom partition column encodings and sorting
600614
if let Some(custom_partition) = custom_partition {
@@ -616,6 +630,66 @@ impl Stream {
616630
props.set_sorting_columns(Some(sorting_column_vec)).build()
617631
}
618632

633+
/// True if this stream's log_source carries the OTel-metrics
634+
/// format. Determines whether per-batch sort and metric_name-first
635+
/// SortingColumn metadata get applied at write time.
636+
fn is_otel_metrics(&self) -> bool {
637+
self.get_log_source()
638+
.iter()
639+
.any(|s| matches!(s.log_source_format, LogSource::OtelMetrics))
640+
}
641+
642+
/// Permute a `RecordBatch` so rows are ordered by
643+
/// `(metric_name ASC, time_partition DESC)`. Required for parquet
644+
/// page-index pruning to be effective on PromQL's
645+
/// `metric_name = 'X'` selector — without this, pages within a row
646+
/// group hold interleaved metrics and per-page min/max stats span
647+
/// every metric in the stream, killing pruning.
648+
///
649+
/// Bails out without sorting when either source column is missing
650+
/// (non-metric stream, schema drift) so the caller can write the
651+
/// batch unchanged.
652+
fn sort_batch_for_metric_pruning(
653+
batch: &RecordBatch,
654+
time_partition_field: &str,
655+
) -> Result<RecordBatch, StagingError> {
656+
use arrow::compute::{SortColumn, kernels::sort::SortOptions, lexsort_to_indices, take};
657+
let schema = batch.schema();
658+
let Some(name_idx) = schema.index_of("metric_name").ok() else {
659+
return Ok(batch.clone());
660+
};
661+
let Some(time_idx) = schema.index_of(time_partition_field).ok() else {
662+
return Ok(batch.clone());
663+
};
664+
if batch.num_rows() < 2 {
665+
return Ok(batch.clone());
666+
}
667+
668+
let sort_cols = vec![
669+
SortColumn {
670+
values: batch.column(name_idx).clone(),
671+
options: Some(SortOptions {
672+
descending: false,
673+
nulls_first: false,
674+
}),
675+
},
676+
SortColumn {
677+
values: batch.column(time_idx).clone(),
678+
options: Some(SortOptions {
679+
descending: true,
680+
nulls_first: false,
681+
}),
682+
},
683+
];
684+
let indices = lexsort_to_indices(&sort_cols, None)?;
685+
let columns: Vec<ArrayRef> = batch
686+
.columns()
687+
.iter()
688+
.map(|c| take(c.as_ref(), &indices, None))
689+
.collect::<Result<_, _>>()?;
690+
Ok(RecordBatch::try_new(schema, columns)?)
691+
}
692+
619693
fn reset_staging_metrics(&self, tenant_id: &Option<String>) {
620694
let tenant_str = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT);
621695
metrics::STAGING_FILES
@@ -739,8 +813,43 @@ impl Stream {
739813
.open(part_path)
740814
.map_err(|_| StagingError::Create)?;
741815
let mut writer = ArrowWriter::try_new(&mut part_file, schema.clone(), Some(props.clone()))?;
742-
for ref record in record_reader.merged_iter(schema.clone(), time_partition.cloned()) {
743-
writer.write(record)?;
816+
let sort_for_metric_pruning = self.is_otel_metrics();
817+
let time_partition_field = time_partition.map_or_else(
818+
|| DEFAULT_TIMESTAMP_KEY.to_string(),
819+
|s| s.as_str().to_string(),
820+
);
821+
822+
if sort_for_metric_pruning {
823+
// Buffer batches up to the row-group target, then
824+
// concat + sort + write as a single contiguous batch. The
825+
// ArrowWriter splits the sorted batch into row groups at the
826+
// same boundary, so the row order survives intact and
827+
// per-page (metric_name min, max) stats narrow to the slice
828+
// each page actually carries.
829+
let target = self.options.row_group_size;
830+
let mut buffer: Vec<RecordBatch> = Vec::new();
831+
let mut buffered_rows: usize = 0;
832+
for record in record_reader.merged_iter(schema.clone(), time_partition.cloned()) {
833+
buffered_rows += record.num_rows();
834+
buffer.push(record);
835+
if buffered_rows >= target {
836+
let combined = arrow::compute::concat_batches(schema, &buffer)?;
837+
let sorted =
838+
Self::sort_batch_for_metric_pruning(&combined, &time_partition_field)?;
839+
writer.write(&sorted)?;
840+
buffer.clear();
841+
buffered_rows = 0;
842+
}
843+
}
844+
if !buffer.is_empty() {
845+
let combined = arrow::compute::concat_batches(schema, &buffer)?;
846+
let sorted = Self::sort_batch_for_metric_pruning(&combined, &time_partition_field)?;
847+
writer.write(&sorted)?;
848+
}
849+
} else {
850+
for ref record in record_reader.merged_iter(schema.clone(), time_partition.cloned()) {
851+
writer.write(record)?;
852+
}
744853
}
745854
writer.close()?;
746855

0 commit comments

Comments
 (0)