diff --git a/src/aggregation/bucket/composite/collector.rs b/src/aggregation/bucket/composite/collector.rs index 36e4cf1af..bb37eb2c7 100644 --- a/src/aggregation/bucket/composite/collector.rs +++ b/src/aggregation/bucket/composite/collector.rs @@ -199,6 +199,17 @@ impl SegmentAggregationCollector for SegmentCompositeCollector { } Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // Composite is a multi-bucket agg with no single value to extract. + None + } } impl SegmentCompositeCollector { diff --git a/src/aggregation/bucket/filter.rs b/src/aggregation/bucket/filter.rs index 5e432b038..f68a67361 100644 --- a/src/aggregation/bucket/filter.rs +++ b/src/aggregation/bucket/filter.rs @@ -674,6 +674,17 @@ impl SegmentAggregationCollector for SegmentFilterCollector } Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // TODO: forward into the inner `sub_agg` for nested order paths (`filter.metric`). + None + } } /// Intermediate result for filter aggregation diff --git a/src/aggregation/bucket/histogram/histogram.rs b/src/aggregation/bucket/histogram/histogram.rs index 5419f2c2e..b5e1bf452 100644 --- a/src/aggregation/bucket/histogram/histogram.rs +++ b/src/aggregation/bucket/histogram/histogram.rs @@ -394,6 +394,17 @@ impl SegmentAggregationCollector for SegmentHistogramCollector { } Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // Histogram is a multi-bucket agg with no single value to extract. + None + } } impl SegmentHistogramCollector { diff --git a/src/aggregation/bucket/range.rs b/src/aggregation/bucket/range.rs index 5fdb9119c..2a7daadfa 100644 --- a/src/aggregation/bucket/range.rs +++ b/src/aggregation/bucket/range.rs @@ -328,6 +328,17 @@ impl SegmentAggregationCollector for SegmentRangeCollector { Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // Range is a multi-bucket agg with no single value to extract. + None + } } /// Build a concrete `SegmentRangeCollector` with either a Vec- or HashMap-backed /// bucket storage, depending on the column type and aggregation level. diff --git a/src/aggregation/bucket/term_agg.rs b/src/aggregation/bucket/term_agg.rs index eba5e240c..edc960c2f 100644 --- a/src/aggregation/bucket/term_agg.rs +++ b/src/aggregation/bucket/term_agg.rs @@ -352,19 +352,15 @@ pub(crate) fn build_segment_term_collector( ))); } - // Validate sub aggregation exists when ordering by sub-aggregation. - { - if let OrderTarget::SubAggregation(sub_agg_name) = &terms_req_data.req.order.target { - let (agg_name, _agg_property) = get_agg_name_and_property(sub_agg_name); - - node.get_sub_agg(agg_name, &req_data.per_request) - .ok_or_else(|| { - TantivyError::InvalidArgument(format!( - "could not find aggregation with name {agg_name} in metric \ - sub_aggregations" - )) - })?; - } + // Validate that the referenced sub-aggregation exists when ordering by one. + if let OrderTarget::SubAggregation(sub_agg_name) = &terms_req_data.req.order.target { + let (agg_name, _agg_property) = get_agg_name_and_property(sub_agg_name); + node.get_sub_agg(agg_name, &req_data.per_request) + .ok_or_else(|| { + TantivyError::InvalidArgument(format!( + "could not find aggregation with name {agg_name} in metric sub_aggregations" + )) + })?; } // Build sub-aggregation blueprint if there are children. @@ -887,6 +883,17 @@ impl SegmentAggregationCollector } Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // Terms is a multi-bucket agg with no single value to extract. + None + } } /// Missing value are represented as a sentinel value in the column. @@ -960,9 +967,6 @@ where ) -> crate::Result { let mut entries: Vec<(u64, Bucket)> = term_buckets.into_vec(); - let order_by_sub_aggregation = - matches!(term_req.req.order.target, OrderTarget::SubAggregation(_)); - match &term_req.req.order.target { OrderTarget::Key => { // We rely on the fact, that term ordinals match the order of the strings @@ -974,10 +978,37 @@ where entries.sort_unstable_by_key(|bucket| bucket.0); } } - OrderTarget::SubAggregation(_name) => { - // don't sort and cut off since it's hard to make assumptions on the quality of the - // results when cutting off du to unknown nature of the sub_aggregation (possible - // to check). + OrderTarget::SubAggregation(sub_agg_path) => { + // Peek segment-level metric values, sort, then fall through to + // `cut_off_buckets`. Like Elasticsearch, we always cut off when ordering + // by a sub-agg: top-K results are approximate and may differ from the + // global ordering, especially for non-monotonic metrics like avg/min. + let coll = sub_agg_collector.as_deref().ok_or_else(|| { + TantivyError::InvalidArgument(format!( + "Could not find sub-aggregation collector for path {sub_agg_path}" + )) + })?; + let (agg_name, agg_prop) = get_agg_name_and_property(sub_agg_path); + // Fetch values up-front; otherwise sort would re-compute per comparison + let mut keyed: Vec<(f64, (u64, Bucket))> = entries + .into_iter() + .map(|bucket| { + let metric_value = coll + .compute_metric_value(bucket.1.bucket_id, agg_name, agg_prop, agg_data) + .unwrap_or(0.0); + (metric_value, bucket) + }) + .collect(); + if term_req.req.order.order == Order::Desc { + keyed.sort_unstable_by(|a, b| { + b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal) + }); + } else { + keyed.sort_unstable_by(|a, b| { + a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal) + }); + } + entries = keyed.into_iter().map(|(_, e)| e).collect(); } OrderTarget::Count => { if term_req.req.order.order == Order::Desc { @@ -988,11 +1019,8 @@ where } } - let (term_doc_count_before_cutoff, sum_other_doc_count) = if order_by_sub_aggregation { - (0, 0) - } else { - cut_off_buckets(&mut entries, term_req.req.segment_size as usize) - }; + let (term_doc_count_before_cutoff, sum_other_doc_count) = + cut_off_buckets(&mut entries, term_req.req.segment_size as usize); let mut dict: FxHashMap = Default::default(); dict.reserve(entries.len()); @@ -1767,6 +1795,263 @@ mod tests { Ok(()) } + #[test] + fn terms_aggregation_order_by_cardinality_desc_single_segment() -> crate::Result<()> { + terms_aggregation_order_by_cardinality_desc(true) + } + #[test] + fn terms_aggregation_order_by_cardinality_desc_multi_segment() -> crate::Result<()> { + terms_aggregation_order_by_cardinality_desc(false) + } + fn terms_aggregation_order_by_cardinality_desc(merge_segments: bool) -> crate::Result<()> { + // Distinct score values per bucket key: A→5, B→1, C→3. + // Order by cardinality desc must yield A, C, B. + let segment_and_terms = vec![vec![ + (1.0, "A".to_string()), + (2.0, "A".to_string()), + (3.0, "A".to_string()), + (4.0, "A".to_string()), + (5.0, "A".to_string()), + (1.0, "B".to_string()), + (1.0, "B".to_string()), + (1.0, "B".to_string()), + (1.0, "C".to_string()), + (2.0, "C".to_string()), + (3.0, "C".to_string()), + ]]; + let index = get_test_index_from_values_and_terms(merge_segments, &segment_and_terms)?; + + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "card": "desc" } + }, + "aggs": { + "card": { "cardinality": { "field": "score" } } + } + } + })) + .unwrap(); + + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][0]["card"]["value"], 5.0); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][1]["card"]["value"], 3.0); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "B"); + assert_eq!(res["my_texts"]["buckets"][2]["card"]["value"], 1.0); + + // Asc engages the segment-cutoff path too (monotonic-safe: discarded buckets had + // local card >= cutoff, so merged card >= cutoff and they cannot be globally smallest). + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "card": "asc" } + }, + "aggs": { + "card": { "cardinality": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "B"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "A"); + + // size=2 with desc engages the segment cutoff: must keep top-2 by cardinality (A, C), + // and `sum_other_doc_count` reflects the dropped B (3 docs). + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "size": 2, + "order": { "card": "desc" } + }, + "aggs": { + "card": { "cardinality": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"].as_array().unwrap().len(), 2); + + // size=2 with asc engages the segment cutoff: must keep bottom-2 by cardinality (B, C). + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "size": 2, + "order": { "card": "asc" } + }, + "aggs": { + "card": { "cardinality": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "B"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"].as_array().unwrap().len(), 2); + + Ok(()) + } + + #[test] + fn terms_aggregation_order_by_sum_single_segment() -> crate::Result<()> { + terms_aggregation_order_by_sum(true) + } + #[test] + fn terms_aggregation_order_by_sum_multi_segment() -> crate::Result<()> { + terms_aggregation_order_by_sum(false) + } + fn terms_aggregation_order_by_sum(merge_segments: bool) -> crate::Result<()> { + // Per-bucket sums on the U64 `score` column (non-negative => sum is monotonic): + // A → 1+2+3+4+5 = 15, B → 1+1+1 = 3, C → 1+2+3 = 6. + let segment_and_terms = vec![ + vec![ + (1.0, "A".to_string()), + (2.0, "A".to_string()), + (3.0, "A".to_string()), + (1.0, "B".to_string()), + (1.0, "C".to_string()), + ], + vec![ + (4.0, "A".to_string()), + (5.0, "A".to_string()), + (1.0, "B".to_string()), + (1.0, "B".to_string()), + (2.0, "C".to_string()), + (3.0, "C".to_string()), + ], + ]; + let index = get_test_index_from_values_and_terms(merge_segments, &segment_and_terms)?; + + // Desc on a Sum metric engages the fast path (column is U64). + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "total": "desc" } + }, + "aggs": { + "total": { "sum": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][0]["total"]["value"], 15.0); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][1]["total"]["value"], 6.0); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "B"); + assert_eq!(res["my_texts"]["buckets"][2]["total"]["value"], 3.0); + + // Asc engages the fast path too — discarded buckets had local sum >= cutoff, + // and merged sum >= local (non-negative addends), so they cannot be globally smallest. + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "total": "asc" } + }, + "aggs": { + "total": { "sum": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "B"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "A"); + + // size=2 desc with cutoff: top-2 by sum (A, C). + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "size": 2, + "order": { "total": "desc" } + }, + "aggs": { + "total": { "sum": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"].as_array().unwrap().len(), 2); + + // Stats sub-property: ordering by `mystats.sum` on a U64 column also engages. + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "mystats.sum": "desc" } + }, + "aggs": { + "mystats": { "stats": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "B"); + + // Sum on a signed column (I64) takes the same cutoff path. Results may be + // approximate near the boundary on adversarial data, but for this dataset the + // top-K is unambiguous. + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "total": "desc" } + }, + "aggs": { + "total": { "sum": { "field": "score_i64" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "B"); + + // Order by extended_stats sub-property exercises compute_metric_value on the + // ExtendedStats collector. A→max=5, B→max=1, C→max=3, so desc by max → A, C, B. + let agg_req: Aggregations = serde_json::from_value(json!({ + "my_texts": { + "terms": { + "field": "string_id", + "order": { "ext.max": "desc" } + }, + "aggs": { + "ext": { "extended_stats": { "field": "score" } } + } + } + })) + .unwrap(); + let res = exec_request(agg_req, &index)?; + assert_eq!(res["my_texts"]["buckets"][0]["key"], "A"); + assert_eq!(res["my_texts"]["buckets"][1]["key"], "C"); + assert_eq!(res["my_texts"]["buckets"][2]["key"], "B"); + + Ok(()) + } + #[test] fn terms_aggregation_test_order_key_single_segment() -> crate::Result<()> { terms_aggregation_test_order_key_merge_segment(true) diff --git a/src/aggregation/bucket/term_missing_agg.rs b/src/aggregation/bucket/term_missing_agg.rs index fb2174490..173426289 100644 --- a/src/aggregation/bucket/term_missing_agg.rs +++ b/src/aggregation/bucket/term_missing_agg.rs @@ -177,6 +177,17 @@ impl SegmentAggregationCollector for TermMissingAgg { } Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // TODO: forward to `sub_agg` for nested order paths (`missing_agg>metric`). + None + } } #[cfg(test)] diff --git a/src/aggregation/metric/cardinality.rs b/src/aggregation/metric/cardinality.rs index e68cd6e48..df0f8566c 100644 --- a/src/aggregation/metric/cardinality.rs +++ b/src/aggregation/metric/cardinality.rs @@ -445,6 +445,28 @@ impl SegmentAggregationCollector for SegmentCardinalityCollector { } Ok(()) } + + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + agg_data: &AggregationsSegmentCtx, + ) -> Option { + let req_data = &agg_data.get_cardinality_req_data(self.accessor_idx); + if req_data.name != sub_agg_name || !sub_agg_property.is_empty() { + return None; + } + let bucket = self.buckets.get(bucket_id as usize)?.as_ref()?; + // For string columns the HLL sketch is empty until materialization; entries holds + // the deduplicated term ordinals seen, which is the exact distinct count. + // For numeric columns the sketch is populated during collect. + if self.column_type == ColumnType::Str { + Some(bucket.entries.len() as f64) + } else { + Some(bucket.cardinality.sketch.estimate().trunc()) + } + } } #[derive(Clone, Debug)] diff --git a/src/aggregation/metric/extended_stats.rs b/src/aggregation/metric/extended_stats.rs index e71426790..1e625d5de 100644 --- a/src/aggregation/metric/extended_stats.rs +++ b/src/aggregation/metric/extended_stats.rs @@ -399,6 +399,26 @@ impl SegmentAggregationCollector for SegmentExtendedStatsCollector { } Ok(()) } + + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + if self.name != sub_agg_name { + return None; + } + let extended = self.buckets.get(bucket_id as usize)?; + // Finalize is a pure read of accumulators — calling it here for the cutoff sort + // doesn't disturb the eventual intermediate result. + extended + .finalize() + .get_value(sub_agg_property) + .ok() + .flatten() + } } #[cfg(test)] diff --git a/src/aggregation/metric/percentiles.rs b/src/aggregation/metric/percentiles.rs index 3cca86996..040f92573 100644 --- a/src/aggregation/metric/percentiles.rs +++ b/src/aggregation/metric/percentiles.rs @@ -312,6 +312,26 @@ impl SegmentAggregationCollector for SegmentPercentilesCollector { } Ok(()) } + + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + agg_data: &AggregationsSegmentCtx, + ) -> Option { + if agg_data.get_metric_req_data(self.accessor_idx).name != sub_agg_name { + return None; + } + let percentile: f64 = sub_agg_property.parse().ok()?; + if !(0.0..=100.0).contains(&percentile) { + return None; + } + let bucket = self.buckets.get(bucket_id as usize)?; + // DDSketch.quantile is a pure read; calling it here for the cutoff sort does + // not affect the intermediate state used for the final result. + bucket.sketch.quantile(percentile / 100.0).ok().flatten() + } } #[cfg(test)] diff --git a/src/aggregation/metric/stats.rs b/src/aggregation/metric/stats.rs index d7f5f1022..eb15427a1 100644 --- a/src/aggregation/metric/stats.rs +++ b/src/aggregation/metric/stats.rs @@ -321,6 +321,40 @@ impl SegmentAggregationCollector } Ok(()) } + + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + if self.name != sub_agg_name { + return None; + } + let stats = self.buckets.get(bucket_id as usize)?; + // The property depends on what we're collecting: + // - StatsType::Stats exposes count/sum/min/max/avg via dotted property. + // - Single-value kinds (Sum/Count/Min/Max/Average) expect an empty property and return + // the value they were configured to collect. + let prop = match self.collecting_for { + StatsType::Stats if !sub_agg_property.is_empty() => sub_agg_property, + StatsType::Sum if sub_agg_property.is_empty() => "sum", + StatsType::Count if sub_agg_property.is_empty() => "count", + StatsType::Max if sub_agg_property.is_empty() => "max", + StatsType::Min if sub_agg_property.is_empty() => "min", + StatsType::Average if sub_agg_property.is_empty() => "avg", + _ => return None, + }; + match prop { + "count" => Some(stats.count as f64), + "sum" => Some(stats.sum), + "min" if stats.count > 0 => Some(stats.min), + "max" if stats.count > 0 => Some(stats.max), + "avg" if stats.count > 0 => Some(stats.sum / stats.count as f64), + _ => None, + } + } } #[inline] diff --git a/src/aggregation/metric/top_hits.rs b/src/aggregation/metric/top_hits.rs index 54e5a5ced..77e2856a4 100644 --- a/src/aggregation/metric/top_hits.rs +++ b/src/aggregation/metric/top_hits.rs @@ -644,6 +644,17 @@ impl SegmentAggregationCollector for TopHitsSegmentCollector { ); Ok(()) } + + fn compute_metric_value( + &self, + _bucket_id: BucketId, + _sub_agg_name: &str, + _sub_agg_property: &str, + _agg_data: &AggregationsSegmentCtx, + ) -> Option { + // top_hits is not a numeric metric and cannot be used as an order target. + None + } } #[cfg(test)] diff --git a/src/aggregation/segment_agg_result.rs b/src/aggregation/segment_agg_result.rs index 7bd13f1cd..01f2151e2 100644 --- a/src/aggregation/segment_agg_result.rs +++ b/src/aggregation/segment_agg_result.rs @@ -76,6 +76,31 @@ pub trait SegmentAggregationCollector: Debug { fn flush(&mut self, _agg_data: &mut AggregationsSegmentCtx) -> crate::Result<()> { Ok(()) } + + /// Compute the segment-level metric value of the named direct-child metric for `bucket_id`. + /// + /// Used by parent term aggs that order by a sub-aggregation: the parent sorts on + /// this value and cuts off at segment time, matching the approximation tradeoff + /// Elasticsearch makes for any sub-agg ordering. + /// + /// `sub_agg_property` is the dotted suffix (e.g. `"sum"` in `mystats.sum`); empty when + /// the metric is a single-value kind such as cardinality. + /// + /// Returns `None` only on name mismatch, unknown property, or empty bucket. Implementations + /// may finalize their per-bucket state (e.g. compute a percentile from a sketch); calls + /// must be idempotent so the final intermediate result is unaffected. + /// + /// No default impl on purpose: every collector must decide explicitly whether it + /// produces a metric value, forwards into children (single-bucket aggs), or rejects + /// the lookup. A silent `None` default would let a parent term agg's cutoff sort all + /// buckets to the same key and drop arbitrary winners. + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + agg_data: &AggregationsSegmentCtx, + ) -> Option; } #[derive(Default)] @@ -137,4 +162,21 @@ impl SegmentAggregationCollector for GenericSegmentAggregationResultsCollector { } Ok(()) } + + fn compute_metric_value( + &self, + bucket_id: BucketId, + sub_agg_name: &str, + sub_agg_property: &str, + agg_data: &AggregationsSegmentCtx, + ) -> Option { + for agg in &self.aggs { + if let Some(value) = + agg.compute_metric_value(bucket_id, sub_agg_name, sub_agg_property, agg_data) + { + return Some(value); + } + } + None + } }