Skip to content

Commit bc54939

Browse files
sort by metric name for otel-metrics stream
1 parent e77d25b commit bc54939

1 file changed

Lines changed: 88 additions & 5 deletions

File tree

src/parseable/streams.rs

Lines changed: 88 additions & 5 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,
596610
nulls_first: true,
597-
}];
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,17 @@ 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()))?;
816+
let sort_for_metric_pruning = self.is_otel_metrics();
817+
let time_partition_field = time_partition
818+
.map(|s| s.as_str().to_string())
819+
.unwrap_or_else(|| DEFAULT_TIMESTAMP_KEY.to_string());
742820
for ref record in record_reader.merged_iter(schema.clone(), time_partition.cloned()) {
743-
writer.write(record)?;
821+
if sort_for_metric_pruning {
822+
let sorted = Self::sort_batch_for_metric_pruning(record, &time_partition_field)?;
823+
writer.write(&sorted)?;
824+
} else {
825+
writer.write(record)?;
826+
}
744827
}
745828
writer.close()?;
746829

0 commit comments

Comments
 (0)