1use std::sync::Arc;
4
5use crate::optimizer::cost::CostCalculator;
6use crate::optimizer::cost::SelectivityEstimator;
7use crate::optimizer::cost_based::ndv::estimate_group_count;
8use crate::optimizer::cost_based::row_estimates::estimate_node_output_rows_logical;
9use crate::optimizer::cost_based::traversal_logical::rewrite_children_logical;
10use crate::optimizer::decision::OptimizationDecision;
11use crate::optimizer::stats::StatsView;
12use crate::planning::plan::logical::logical_node_traits::LogicalSingleInputNode;
13use crate::planning::plan::logical::LogicalNodeEnum;
14use linkrs_core::types::ContextualExpression;
15
16use super::context::AggregateContext;
17use super::strategy::{AggregateStrategy, AggregateStrategyDecision, SelectionReason};
18
19#[derive(Debug)]
23pub struct AggregateStrategySelector {
24 cost_calculator: Arc<CostCalculator>,
25}
26
27impl AggregateStrategySelector {
28 pub fn new(cost_calculator: Arc<CostCalculator>) -> Self {
30 Self { cost_calculator }
31 }
32
33 pub fn select_strategy(
41 &self,
42 space: &str,
43 context: &AggregateContext,
44 ) -> AggregateStrategyDecision {
45 self.select_strategy_internal(space, context, false)
46 }
47
48 pub fn select_strategy_with_memory_pressure(
59 &self,
60 space: &str,
61 context: &AggregateContext,
62 ) -> AggregateStrategyDecision {
63 self.select_strategy_internal(space, context, true)
64 }
65
66 fn select_strategy_internal(
68 &self,
69 space: &str,
70 context: &AggregateContext,
71 consider_memory_pressure: bool,
72 ) -> AggregateStrategyDecision {
73 if context.input_is_sorted && context.sort_keys_match_group_keys {
75 return self.create_streaming_aggregate_decision(space, context);
76 }
77
78 if !context.is_deterministic {
80 let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
81 let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
82 let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
83 return AggregateStrategyDecision {
84 strategy: AggregateStrategy::HashAggregate,
85 estimated_output_rows: group_by_cardinality.max(1),
86 estimated_cost: hash_cost,
87 estimated_memory_bytes: hash_memory,
88 reason: SelectionReason::CostBased {
89 hash_cost,
90 sort_cost: hash_cost * 1.5, },
92 };
93 }
94
95 let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
97
98 let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
100 let sort_cost = self.calculate_sort_aggregate_cost(context);
101
102 let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
104 let memory_constrained = context.memory_limit > 0 && hash_memory > context.memory_limit;
105
106 let memory_pressure_threshold =
108 self.cost_calculator.config().memory_pressure_threshold as u64;
109 let under_memory_pressure =
110 consider_memory_pressure && hash_memory > memory_pressure_threshold;
111
112 let (strategy, reason) = if memory_constrained || under_memory_pressure {
114 if under_memory_pressure && sort_cost < hash_cost * 1.5 {
117 (
119 AggregateStrategy::SortAggregate,
120 SelectionReason::MemoryConstrained,
121 )
122 } else if memory_constrained {
123 (
124 AggregateStrategy::SortAggregate,
125 SelectionReason::MemoryConstrained,
126 )
127 } else {
128 (
130 AggregateStrategy::HashAggregate,
131 SelectionReason::CostBased {
132 hash_cost,
133 sort_cost,
134 },
135 )
136 }
137 } else if context.input_rows
138 < self
139 .cost_calculator
140 .config()
141 .strategy_thresholds
142 .small_dataset_threshold
143 {
144 (
146 AggregateStrategy::HashAggregate,
147 SelectionReason::SmallDataSet,
148 )
149 } else if group_by_cardinality
150 < self
151 .cost_calculator
152 .config()
153 .strategy_thresholds
154 .low_cardinality_threshold
155 {
156 if sort_cost < hash_cost * 1.2 {
158 (
159 AggregateStrategy::SortAggregate,
160 SelectionReason::LowCardinality,
161 )
162 } else {
163 (
164 AggregateStrategy::HashAggregate,
165 SelectionReason::CostBased {
166 hash_cost,
167 sort_cost,
168 },
169 )
170 }
171 } else if group_by_cardinality
172 > (context.input_rows as f64
173 * self
174 .cost_calculator
175 .config()
176 .strategy_thresholds
177 .high_cardinality_ratio) as u64
178 {
179 (
181 AggregateStrategy::HashAggregate,
182 SelectionReason::HighCardinality,
183 )
184 } else {
185 if hash_cost <= sort_cost {
187 (
188 AggregateStrategy::HashAggregate,
189 SelectionReason::CostBased {
190 hash_cost,
191 sort_cost,
192 },
193 )
194 } else {
195 (
196 AggregateStrategy::SortAggregate,
197 SelectionReason::CostBased {
198 hash_cost,
199 sort_cost,
200 },
201 )
202 }
203 };
204
205 let estimated_cost = match strategy {
206 AggregateStrategy::HashAggregate => hash_cost,
207 AggregateStrategy::SortAggregate => sort_cost,
208 AggregateStrategy::StreamingAggregate => {
209 self.calculate_streaming_aggregate_cost(context)
210 }
211 };
212
213 AggregateStrategyDecision {
214 strategy,
215 estimated_output_rows: group_by_cardinality.max(1),
216 estimated_cost,
217 estimated_memory_bytes: match strategy {
218 AggregateStrategy::HashAggregate => hash_memory,
219 AggregateStrategy::SortAggregate => self.estimate_sort_memory_usage(context),
220 AggregateStrategy::StreamingAggregate => {
221 self.estimate_streaming_memory_usage(context)
222 }
223 },
224 reason,
225 }
226 }
227
228 pub fn select_strategy_quick(
230 &self,
231 input_rows: u64,
232 group_key_count: usize,
233 _agg_function_count: usize,
234 ) -> AggregateStrategy {
235 let thresholds = self.cost_calculator.config().strategy_thresholds;
236 if input_rows < thresholds.small_dataset_threshold {
237 return AggregateStrategy::HashAggregate;
238 }
239
240 let cardinality = self.estimate_cardinality_quick(input_rows, group_key_count);
242
243 if cardinality < thresholds.low_cardinality_threshold {
244 AggregateStrategy::SortAggregate
245 } else {
246 AggregateStrategy::HashAggregate
247 }
248 }
249
250 fn estimate_group_by_cardinality(&self, space: &str, context: &AggregateContext) -> u64 {
255 if let Some(table_name) = self.get_table_name_from_context(context) {
257 if let Some(cardinality) = self
258 .cost_calculator
259 .statistics_manager()
260 .get_combined_cardinality(space, Some(&table_name), &context.group_keys)
261 {
262 return cardinality.min(context.input_rows).max(1);
263 }
264 }
265
266 self.estimate_cardinality_quick(context.input_rows, context.group_keys.len())
268 }
269
270 fn get_table_name_from_context(&self, context: &AggregateContext) -> Option<String> {
273 context.table_name.clone()
274 }
275
276 fn estimate_cardinality_quick(&self, input_rows: u64, key_count: usize) -> u64 {
278 estimate_group_count(input_rows, key_count)
279 }
280
281 fn calculate_hash_aggregate_cost(
283 &self,
284 context: &AggregateContext,
285 _group_by_cardinality: u64,
286 ) -> f64 {
287 self.cost_calculator.calculate_aggregate_cost(
288 context.input_rows,
289 context.agg_function_count,
290 context.group_keys.len(),
291 )
292 }
293
294 fn calculate_sort_aggregate_cost(&self, context: &AggregateContext) -> f64 {
296 let sort_cost = self.cost_calculator.calculate_sort_cost(
298 context.input_rows,
299 context.group_keys.len(),
300 None,
301 );
302
303 let agg_cost = context.input_rows as f64
305 * context.agg_function_count as f64
306 * self.cost_calculator.config().cpu_operator_cost
307 * 0.5; sort_cost + agg_cost
310 }
311
312 fn calculate_streaming_aggregate_cost(&self, context: &AggregateContext) -> f64 {
314 context.input_rows as f64
316 * context.agg_function_count as f64
317 * self.cost_calculator.config().cpu_operator_cost
318 }
319
320 fn estimate_hash_memory_usage(
322 &self,
323 _context: &AggregateContext,
324 group_by_cardinality: u64,
325 ) -> u64 {
326 group_by_cardinality * self.cost_calculator.config().row_width_bytes as u64 * 2
329 }
330
331 fn estimate_sort_memory_usage(&self, context: &AggregateContext) -> u64 {
333 let row_size = self.cost_calculator.config().row_width_bytes as u64;
335 context.input_rows * row_size
336 }
337
338 fn estimate_streaming_memory_usage(&self, _context: &AggregateContext) -> u64 {
340 self.cost_calculator.config().row_width_bytes as u64 * 2
342 }
343
344 fn create_streaming_aggregate_decision(
346 &self,
347 space: &str,
348 context: &AggregateContext,
349 ) -> AggregateStrategyDecision {
350 let estimated_cost = self.calculate_streaming_aggregate_cost(context);
351 let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
352
353 AggregateStrategyDecision {
354 strategy: AggregateStrategy::StreamingAggregate,
355 estimated_output_rows: group_by_cardinality.max(1),
356 estimated_cost,
357 estimated_memory_bytes: self.estimate_streaming_memory_usage(context),
358 reason: SelectionReason::InputAlreadySorted,
359 }
360 }
361
362 pub fn update_decision(
364 &self,
365 space: &str,
366 decision: &mut OptimizationDecision,
367 context: &AggregateContext,
368 ) {
369 let strategy_decision = self.select_strategy(space, context);
370
371 let rule_id = match strategy_decision.strategy {
374 AggregateStrategy::HashAggregate => {
375 crate::optimizer::decision::RewriteRuleId::AggregateOptimization
376 }
377 AggregateStrategy::SortAggregate => {
378 crate::optimizer::decision::RewriteRuleId::AggregateOptimization
379 }
380 AggregateStrategy::StreamingAggregate => {
381 crate::optimizer::decision::RewriteRuleId::AggregateOptimization
382 }
383 };
384
385 if !decision.rewrite_rules.contains(&rule_id) {
386 decision.rewrite_rules.push(rule_id);
387 }
388 }
389
390 pub fn analyze_aggregation_complexity(&self, agg_functions: &[ContextualExpression]) -> f64 {
396 agg_functions
397 .iter()
398 .filter_map(|f| f.get_expression())
399 .map(|expr| self.cost_calculator.calculate_expression_cost(&expr))
400 .sum()
401 }
402
403 pub fn select_strategy_comprehensive(
411 &self,
412 space: &str,
413 context: &AggregateContext,
414 agg_functions: &[ContextualExpression],
415 ) -> AggregateStrategyDecision {
416 if context.input_is_sorted && context.sort_keys_match_group_keys {
418 return self.create_streaming_aggregate_decision(space, context);
419 }
420
421 let total_expression_cost = self.analyze_aggregation_complexity(agg_functions);
423 let avg_expression_cost = if agg_functions.is_empty() {
424 0.0
425 } else {
426 total_expression_cost / agg_functions.len() as f64
427 };
428
429 let mut decision = self.select_strategy_with_memory_pressure(space, context);
431
432 if decision.strategy == AggregateStrategy::SortAggregate
435 && avg_expression_cost > self.cost_calculator.config().function_call_base_cost * 2.0
436 {
437 let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
438 let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
439 let sort_cost = decision.estimated_cost;
440
441 if hash_cost < sort_cost * 1.3 {
443 let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
444 decision = AggregateStrategyDecision {
445 strategy: AggregateStrategy::HashAggregate,
446 estimated_output_rows: group_by_cardinality.max(1),
447 estimated_cost: hash_cost,
448 estimated_memory_bytes: hash_memory,
449 reason: SelectionReason::CostBased {
450 hash_cost,
451 sort_cost,
452 },
453 };
454 }
455 }
456
457 decision
458 }
459}
460
461pub fn walk_aggregate_strategies_logical(
474 node: &LogicalNodeEnum,
475 stats: &StatsView,
476 selector: &AggregateStrategySelector,
477 selectivity: &SelectivityEstimator,
478 notes: &mut Vec<String>,
479) -> LogicalNodeEnum {
480 use LogicalNodeEnum::*;
481
482 if let Aggregate(aggregate) = node {
483 let input_rows = estimate_node_output_rows_logical(aggregate.input(), stats, selectivity);
484 let context = AggregateContext {
485 input_rows,
486 group_keys: aggregate
487 .group_key_exprs
488 .iter()
489 .map(|e| e.to_expression_string())
490 .collect(),
491 agg_function_count: aggregate.aggregation_functions.len(),
492 memory_limit: 0,
493 input_is_sorted: false,
494 sort_keys_match_group_keys: false,
495 is_deterministic: true,
496 complexity_score: 0,
497 table_name: None,
498 };
499 let decision = selector.select_strategy(stats.space().unwrap_or(""), &context);
500 notes.push(format!(
501 "aggregate: strategy={:?} (reason={:?}, est_rows={})",
502 decision.strategy, decision.reason, decision.estimated_output_rows
503 ));
504 }
505
506 let mut closure = |child: &LogicalNodeEnum| {
507 walk_aggregate_strategies_logical(child, stats, selector, selectivity, notes)
508 };
509 rewrite_children_logical(node, &mut closure)
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::optimizer::stats::StatisticsManager;
516
517 fn create_test_selector() -> AggregateStrategySelector {
518 let stats_manager = Arc::new(StatisticsManager::new());
519 let cost_calculator = Arc::new(CostCalculator::new(stats_manager));
520 AggregateStrategySelector::new(cost_calculator)
521 }
522
523 #[test]
524 fn test_streaming_aggregate_when_sorted() {
525 let selector = create_test_selector();
526 let context = AggregateContext {
527 input_rows: 10000,
528 group_keys: vec!["category".to_string()],
529 agg_function_count: 2,
530 memory_limit: 0,
531 input_is_sorted: true,
532 sort_keys_match_group_keys: true,
533 is_deterministic: true,
534 complexity_score: 0,
535 table_name: None,
536 };
537
538 let decision = selector.select_strategy("test", &context);
539 assert_eq!(decision.strategy, AggregateStrategy::StreamingAggregate);
540 matches!(decision.reason, SelectionReason::InputAlreadySorted);
541 }
542
543 #[test]
544 fn test_hash_aggregate_for_small_data() {
545 let selector = create_test_selector();
546 let context = AggregateContext {
547 input_rows: 500,
548 group_keys: vec!["category".to_string()],
549 agg_function_count: 1,
550 memory_limit: 0,
551 input_is_sorted: false,
552 sort_keys_match_group_keys: false,
553 is_deterministic: true,
554 complexity_score: 0,
555 table_name: None,
556 };
557
558 let decision = selector.select_strategy("test", &context);
559 assert_eq!(decision.strategy, AggregateStrategy::HashAggregate);
560 matches!(decision.reason, SelectionReason::SmallDataSet);
561 }
562
563 #[test]
564 fn test_memory_constrained_fallback() {
565 let selector = create_test_selector();
566 let context = AggregateContext {
567 input_rows: 100000,
568 group_keys: vec!["category".to_string(), "subcategory".to_string()],
569 agg_function_count: 3,
570 memory_limit: 1024, input_is_sorted: false,
572 sort_keys_match_group_keys: false,
573 is_deterministic: true,
574 complexity_score: 0,
575 table_name: None,
576 };
577
578 let decision = selector.select_strategy("test", &context);
579 assert_eq!(decision.strategy, AggregateStrategy::SortAggregate);
580 matches!(decision.reason, SelectionReason::MemoryConstrained);
581 }
582
583 #[test]
584 fn test_quick_selection() {
585 let selector = create_test_selector();
586
587 let strategy = selector.select_strategy_quick(500, 1, 1);
589 assert_eq!(strategy, AggregateStrategy::HashAggregate);
590
591 let strategy = selector.select_strategy_quick(100000, 10, 1);
593 assert_eq!(strategy, AggregateStrategy::SortAggregate);
594
595 let strategy = selector.select_strategy_quick(100000, 1, 1);
597 assert_eq!(strategy, AggregateStrategy::HashAggregate);
598 }
599
600 #[test]
601 fn test_cardinality_estimation() {
602 let selector = create_test_selector();
603
604 let cardinality = selector.estimate_cardinality_quick(1000, 0);
606 assert_eq!(cardinality, 1);
607
608 let cardinality = selector.estimate_cardinality_quick(10000, 1);
610 assert!(cardinality > 100);
611
612 let cardinality = selector.estimate_cardinality_quick(10000, 5);
614 assert!(cardinality < 1000);
615 }
616
617 #[test]
618 fn test_memory_pressure_aware_selection() {
619 let selector = create_test_selector();
620
621 let context = AggregateContext {
624 input_rows: 100000,
625 group_keys: vec!["category".to_string()],
626 agg_function_count: 2,
627 memory_limit: 0, input_is_sorted: false,
629 sort_keys_match_group_keys: false,
630 is_deterministic: true,
631 complexity_score: 0,
632 table_name: None,
633 };
634
635 let _normal_decision = selector.select_strategy("test", &context);
637
638 let pressure_decision = selector.select_strategy_with_memory_pressure("test", &context);
640
641 assert!(pressure_decision.estimated_cost > 0.0);
643 assert!(pressure_decision.estimated_memory_bytes > 0);
644 }
645
646 #[test]
647 fn test_analyze_aggregation_complexity() {
648 let selector = create_test_selector();
649
650 let empty_complexity = selector.analyze_aggregation_complexity(&[]);
652 assert_eq!(empty_complexity, 0.0);
653
654 use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
656 use linkrs_core::types::expr::Expression;
657 use linkrs_core::value::Value;
658
659 let ctx = ExpressionAnalysisContext::new();
660 let expr = Expression::Literal(Value::Int(42));
661 let id = ctx.register_expression(linkrs_core::types::expr::ExpressionMeta::new(expr));
662 let simple_expr = ContextualExpression::new(id, std::sync::Arc::new(ctx));
663 let simple_complexity = selector.analyze_aggregation_complexity(&[simple_expr]);
664 assert!(simple_complexity >= 0.0);
665 }
666
667 #[test]
668 fn test_comprehensive_strategy_selection() {
669 let selector = create_test_selector();
670
671 let context = AggregateContext {
672 input_rows: 10000,
673 group_keys: vec!["category".to_string()],
674 agg_function_count: 2,
675 memory_limit: 0,
676 input_is_sorted: false,
677 sort_keys_match_group_keys: false,
678 is_deterministic: true,
679 complexity_score: 0,
680 table_name: None,
681 };
682
683 let decision = selector.select_strategy_comprehensive("test", &context, &[]);
685 assert!(decision.estimated_cost > 0.0);
686 assert!(decision.estimated_memory_bytes > 0);
687 }
688
689 #[test]
690 fn logical_walk_emits_aggregate_strategy_note() {
691 use crate::optimizer::cost::SelectivityEstimator;
692 use crate::optimizer::stats::StatsView;
693 use crate::planning::plan::logical::logical_nodes::access::LogicalScanVerticesNode;
694 use crate::planning::plan::logical::logical_nodes::operation::LogicalAggregateNode;
695 use crate::planning::plan::logical::LogicalNodeEnum;
696 use linkrs_core::types::operators::AggregateFunction;
697
698 let stats_manager = Arc::new(StatisticsManager::new());
699 let cost_calculator = Arc::new(CostCalculator::new(stats_manager.clone()));
700 let selector = AggregateStrategySelector::new(cost_calculator);
701 let selectivity = SelectivityEstimator::new(stats_manager.clone());
702 let stats = StatsView::new(&stats_manager, Some("test"));
703
704 let scan = LogicalNodeEnum::ScanVertices(LogicalScanVerticesNode {
705 id: 1,
706 space_id: 1,
707 space_name: "test".to_string(),
708 tag: Some("person".to_string()),
709 expression: None,
710 limit: None,
711 projected_properties: vec![],
712 index_hint: None,
713 estimated_cardinality: None,
714 output_var: None,
715 col_names: vec![],
716 column_types: vec![],
717 });
718 let ctx = std::sync::Arc::new(
719 linkrs_core::types::expr::expression_context::ExpressionAnalysisContext::new(),
720 );
721 let expr = linkrs_core::Expression::Variable("n.age".to_string());
722 let meta = linkrs_core::types::expr::ExpressionMeta::new(expr);
723 let id = ctx.register_expression(meta);
724 let ctx_expr = linkrs_core::types::expr::contextual::ContextualExpression::new(id, ctx);
725 let aggregate = LogicalNodeEnum::Aggregate(LogicalAggregateNode {
726 id: 2,
727 input: Some(Box::new(scan)),
728 group_key_exprs: vec![ctx_expr],
729 aggregation_functions: vec![AggregateFunction::Count],
730 aggregation_args: vec![],
731 aggregation_distinct: vec![],
732 aggregation_filters: vec![],
733 grouping_sets: vec![],
734 output_var: None,
735 col_names: vec![],
736 column_types: vec![],
737 });
738
739 let mut notes = Vec::new();
740 let rewritten = walk_aggregate_strategies_logical(
741 &aggregate,
742 &stats,
743 &selector,
744 &selectivity,
745 &mut notes,
746 );
747
748 assert!(matches!(rewritten, LogicalNodeEnum::Aggregate(_)));
750 assert_eq!(notes.len(), 1);
751 assert!(notes[0].starts_with("aggregate: strategy="));
752 assert!(notes[0].contains("est_rows="));
753 }
754}