1717 *
1818 */
1919
20- use arrow_array:: RecordBatch ;
20+ use arrow_array:: { ArrayRef , RecordBatch } ;
2121use arrow_ipc:: reader:: StreamReader ;
2222use arrow_schema:: { Field , Fields , Schema } ;
2323use 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