use std::sync::Arc;
use crate::optimizer::cost::CostCalculator;
use crate::optimizer::cost::SelectivityEstimator;
use crate::optimizer::cost_based::ndv::estimate_group_count;
use crate::optimizer::cost_based::row_estimates::estimate_node_output_rows_logical;
use crate::optimizer::cost_based::traversal_logical::rewrite_children_logical;
use crate::optimizer::decision::OptimizationDecision;
use crate::optimizer::stats::StatsView;
use crate::planning::plan::logical::logical_node_traits::LogicalSingleInputNode;
use crate::planning::plan::logical::LogicalNodeEnum;
use linkrs_core::types::ContextualExpression;
use super::context::AggregateContext;
use super::strategy::{AggregateStrategy, AggregateStrategyDecision, SelectionReason};
#[derive(Debug)]
pub struct AggregateStrategySelector {
cost_calculator: Arc<CostCalculator>,
}
impl AggregateStrategySelector {
pub fn new(cost_calculator: Arc<CostCalculator>) -> Self {
Self { cost_calculator }
}
pub fn select_strategy(
&self,
space: &str,
context: &AggregateContext,
) -> AggregateStrategyDecision {
self.select_strategy_internal(space, context, false)
}
pub fn select_strategy_with_memory_pressure(
&self,
space: &str,
context: &AggregateContext,
) -> AggregateStrategyDecision {
self.select_strategy_internal(space, context, true)
}
fn select_strategy_internal(
&self,
space: &str,
context: &AggregateContext,
consider_memory_pressure: bool,
) -> AggregateStrategyDecision {
if context.input_is_sorted && context.sort_keys_match_group_keys {
return self.create_streaming_aggregate_decision(space, context);
}
if !context.is_deterministic {
let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
return AggregateStrategyDecision {
strategy: AggregateStrategy::HashAggregate,
estimated_output_rows: group_by_cardinality.max(1),
estimated_cost: hash_cost,
estimated_memory_bytes: hash_memory,
reason: SelectionReason::CostBased {
hash_cost,
sort_cost: hash_cost * 1.5, },
};
}
let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
let sort_cost = self.calculate_sort_aggregate_cost(context);
let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
let memory_constrained = context.memory_limit > 0 && hash_memory > context.memory_limit;
let memory_pressure_threshold =
self.cost_calculator.config().memory_pressure_threshold as u64;
let under_memory_pressure =
consider_memory_pressure && hash_memory > memory_pressure_threshold;
let (strategy, reason) = if memory_constrained || under_memory_pressure {
if under_memory_pressure && sort_cost < hash_cost * 1.5 {
(
AggregateStrategy::SortAggregate,
SelectionReason::MemoryConstrained,
)
} else if memory_constrained {
(
AggregateStrategy::SortAggregate,
SelectionReason::MemoryConstrained,
)
} else {
(
AggregateStrategy::HashAggregate,
SelectionReason::CostBased {
hash_cost,
sort_cost,
},
)
}
} else if context.input_rows
< self
.cost_calculator
.config()
.strategy_thresholds
.small_dataset_threshold
{
(
AggregateStrategy::HashAggregate,
SelectionReason::SmallDataSet,
)
} else if group_by_cardinality
< self
.cost_calculator
.config()
.strategy_thresholds
.low_cardinality_threshold
{
if sort_cost < hash_cost * 1.2 {
(
AggregateStrategy::SortAggregate,
SelectionReason::LowCardinality,
)
} else {
(
AggregateStrategy::HashAggregate,
SelectionReason::CostBased {
hash_cost,
sort_cost,
},
)
}
} else if group_by_cardinality
> (context.input_rows as f64
* self
.cost_calculator
.config()
.strategy_thresholds
.high_cardinality_ratio) as u64
{
(
AggregateStrategy::HashAggregate,
SelectionReason::HighCardinality,
)
} else {
if hash_cost <= sort_cost {
(
AggregateStrategy::HashAggregate,
SelectionReason::CostBased {
hash_cost,
sort_cost,
},
)
} else {
(
AggregateStrategy::SortAggregate,
SelectionReason::CostBased {
hash_cost,
sort_cost,
},
)
}
};
let estimated_cost = match strategy {
AggregateStrategy::HashAggregate => hash_cost,
AggregateStrategy::SortAggregate => sort_cost,
AggregateStrategy::StreamingAggregate => {
self.calculate_streaming_aggregate_cost(context)
}
};
AggregateStrategyDecision {
strategy,
estimated_output_rows: group_by_cardinality.max(1),
estimated_cost,
estimated_memory_bytes: match strategy {
AggregateStrategy::HashAggregate => hash_memory,
AggregateStrategy::SortAggregate => self.estimate_sort_memory_usage(context),
AggregateStrategy::StreamingAggregate => {
self.estimate_streaming_memory_usage(context)
}
},
reason,
}
}
pub fn select_strategy_quick(
&self,
input_rows: u64,
group_key_count: usize,
_agg_function_count: usize,
) -> AggregateStrategy {
let thresholds = self.cost_calculator.config().strategy_thresholds;
if input_rows < thresholds.small_dataset_threshold {
return AggregateStrategy::HashAggregate;
}
let cardinality = self.estimate_cardinality_quick(input_rows, group_key_count);
if cardinality < thresholds.low_cardinality_threshold {
AggregateStrategy::SortAggregate
} else {
AggregateStrategy::HashAggregate
}
}
fn estimate_group_by_cardinality(&self, space: &str, context: &AggregateContext) -> u64 {
if let Some(table_name) = self.get_table_name_from_context(context) {
if let Some(cardinality) = self
.cost_calculator
.statistics_manager()
.get_combined_cardinality(space, Some(&table_name), &context.group_keys)
{
return cardinality.min(context.input_rows).max(1);
}
}
self.estimate_cardinality_quick(context.input_rows, context.group_keys.len())
}
fn get_table_name_from_context(&self, context: &AggregateContext) -> Option<String> {
context.table_name.clone()
}
fn estimate_cardinality_quick(&self, input_rows: u64, key_count: usize) -> u64 {
estimate_group_count(input_rows, key_count)
}
fn calculate_hash_aggregate_cost(
&self,
context: &AggregateContext,
_group_by_cardinality: u64,
) -> f64 {
self.cost_calculator.calculate_aggregate_cost(
context.input_rows,
context.agg_function_count,
context.group_keys.len(),
)
}
fn calculate_sort_aggregate_cost(&self, context: &AggregateContext) -> f64 {
let sort_cost = self.cost_calculator.calculate_sort_cost(
context.input_rows,
context.group_keys.len(),
None,
);
let agg_cost = context.input_rows as f64
* context.agg_function_count as f64
* self.cost_calculator.config().cpu_operator_cost
* 0.5;
sort_cost + agg_cost
}
fn calculate_streaming_aggregate_cost(&self, context: &AggregateContext) -> f64 {
context.input_rows as f64
* context.agg_function_count as f64
* self.cost_calculator.config().cpu_operator_cost
}
fn estimate_hash_memory_usage(
&self,
_context: &AggregateContext,
group_by_cardinality: u64,
) -> u64 {
group_by_cardinality * self.cost_calculator.config().row_width_bytes as u64 * 2
}
fn estimate_sort_memory_usage(&self, context: &AggregateContext) -> u64 {
let row_size = self.cost_calculator.config().row_width_bytes as u64;
context.input_rows * row_size
}
fn estimate_streaming_memory_usage(&self, _context: &AggregateContext) -> u64 {
self.cost_calculator.config().row_width_bytes as u64 * 2
}
fn create_streaming_aggregate_decision(
&self,
space: &str,
context: &AggregateContext,
) -> AggregateStrategyDecision {
let estimated_cost = self.calculate_streaming_aggregate_cost(context);
let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
AggregateStrategyDecision {
strategy: AggregateStrategy::StreamingAggregate,
estimated_output_rows: group_by_cardinality.max(1),
estimated_cost,
estimated_memory_bytes: self.estimate_streaming_memory_usage(context),
reason: SelectionReason::InputAlreadySorted,
}
}
pub fn update_decision(
&self,
space: &str,
decision: &mut OptimizationDecision,
context: &AggregateContext,
) {
let strategy_decision = self.select_strategy(space, context);
let rule_id = match strategy_decision.strategy {
AggregateStrategy::HashAggregate => {
crate::optimizer::decision::RewriteRuleId::AggregateOptimization
}
AggregateStrategy::SortAggregate => {
crate::optimizer::decision::RewriteRuleId::AggregateOptimization
}
AggregateStrategy::StreamingAggregate => {
crate::optimizer::decision::RewriteRuleId::AggregateOptimization
}
};
if !decision.rewrite_rules.contains(&rule_id) {
decision.rewrite_rules.push(rule_id);
}
}
pub fn analyze_aggregation_complexity(&self, agg_functions: &[ContextualExpression]) -> f64 {
agg_functions
.iter()
.filter_map(|f| f.get_expression())
.map(|expr| self.cost_calculator.calculate_expression_cost(&expr))
.sum()
}
pub fn select_strategy_comprehensive(
&self,
space: &str,
context: &AggregateContext,
agg_functions: &[ContextualExpression],
) -> AggregateStrategyDecision {
if context.input_is_sorted && context.sort_keys_match_group_keys {
return self.create_streaming_aggregate_decision(space, context);
}
let total_expression_cost = self.analyze_aggregation_complexity(agg_functions);
let avg_expression_cost = if agg_functions.is_empty() {
0.0
} else {
total_expression_cost / agg_functions.len() as f64
};
let mut decision = self.select_strategy_with_memory_pressure(space, context);
if decision.strategy == AggregateStrategy::SortAggregate
&& avg_expression_cost > self.cost_calculator.config().function_call_base_cost * 2.0
{
let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
let sort_cost = decision.estimated_cost;
if hash_cost < sort_cost * 1.3 {
let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
decision = AggregateStrategyDecision {
strategy: AggregateStrategy::HashAggregate,
estimated_output_rows: group_by_cardinality.max(1),
estimated_cost: hash_cost,
estimated_memory_bytes: hash_memory,
reason: SelectionReason::CostBased {
hash_cost,
sort_cost,
},
};
}
}
decision
}
}
pub fn walk_aggregate_strategies_logical(
node: &LogicalNodeEnum,
stats: &StatsView,
selector: &AggregateStrategySelector,
selectivity: &SelectivityEstimator,
notes: &mut Vec<String>,
) -> LogicalNodeEnum {
use LogicalNodeEnum::*;
if let Aggregate(aggregate) = node {
let input_rows = estimate_node_output_rows_logical(aggregate.input(), stats, selectivity);
let context = AggregateContext {
input_rows,
group_keys: aggregate
.group_key_exprs
.iter()
.map(|e| e.to_expression_string())
.collect(),
agg_function_count: aggregate.aggregation_functions.len(),
memory_limit: 0,
input_is_sorted: false,
sort_keys_match_group_keys: false,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let decision = selector.select_strategy(stats.space().unwrap_or(""), &context);
notes.push(format!(
"aggregate: strategy={:?} (reason={:?}, est_rows={})",
decision.strategy, decision.reason, decision.estimated_output_rows
));
}
let mut closure = |child: &LogicalNodeEnum| {
walk_aggregate_strategies_logical(child, stats, selector, selectivity, notes)
};
rewrite_children_logical(node, &mut closure)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optimizer::stats::StatisticsManager;
fn create_test_selector() -> AggregateStrategySelector {
let stats_manager = Arc::new(StatisticsManager::new());
let cost_calculator = Arc::new(CostCalculator::new(stats_manager));
AggregateStrategySelector::new(cost_calculator)
}
#[test]
fn test_streaming_aggregate_when_sorted() {
let selector = create_test_selector();
let context = AggregateContext {
input_rows: 10000,
group_keys: vec!["category".to_string()],
agg_function_count: 2,
memory_limit: 0,
input_is_sorted: true,
sort_keys_match_group_keys: true,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let decision = selector.select_strategy("test", &context);
assert_eq!(decision.strategy, AggregateStrategy::StreamingAggregate);
matches!(decision.reason, SelectionReason::InputAlreadySorted);
}
#[test]
fn test_hash_aggregate_for_small_data() {
let selector = create_test_selector();
let context = AggregateContext {
input_rows: 500,
group_keys: vec!["category".to_string()],
agg_function_count: 1,
memory_limit: 0,
input_is_sorted: false,
sort_keys_match_group_keys: false,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let decision = selector.select_strategy("test", &context);
assert_eq!(decision.strategy, AggregateStrategy::HashAggregate);
matches!(decision.reason, SelectionReason::SmallDataSet);
}
#[test]
fn test_memory_constrained_fallback() {
let selector = create_test_selector();
let context = AggregateContext {
input_rows: 100000,
group_keys: vec!["category".to_string(), "subcategory".to_string()],
agg_function_count: 3,
memory_limit: 1024, input_is_sorted: false,
sort_keys_match_group_keys: false,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let decision = selector.select_strategy("test", &context);
assert_eq!(decision.strategy, AggregateStrategy::SortAggregate);
matches!(decision.reason, SelectionReason::MemoryConstrained);
}
#[test]
fn test_quick_selection() {
let selector = create_test_selector();
let strategy = selector.select_strategy_quick(500, 1, 1);
assert_eq!(strategy, AggregateStrategy::HashAggregate);
let strategy = selector.select_strategy_quick(100000, 10, 1);
assert_eq!(strategy, AggregateStrategy::SortAggregate);
let strategy = selector.select_strategy_quick(100000, 1, 1);
assert_eq!(strategy, AggregateStrategy::HashAggregate);
}
#[test]
fn test_cardinality_estimation() {
let selector = create_test_selector();
let cardinality = selector.estimate_cardinality_quick(1000, 0);
assert_eq!(cardinality, 1);
let cardinality = selector.estimate_cardinality_quick(10000, 1);
assert!(cardinality > 100);
let cardinality = selector.estimate_cardinality_quick(10000, 5);
assert!(cardinality < 1000);
}
#[test]
fn test_memory_pressure_aware_selection() {
let selector = create_test_selector();
let context = AggregateContext {
input_rows: 100000,
group_keys: vec!["category".to_string()],
agg_function_count: 2,
memory_limit: 0, input_is_sorted: false,
sort_keys_match_group_keys: false,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let _normal_decision = selector.select_strategy("test", &context);
let pressure_decision = selector.select_strategy_with_memory_pressure("test", &context);
assert!(pressure_decision.estimated_cost > 0.0);
assert!(pressure_decision.estimated_memory_bytes > 0);
}
#[test]
fn test_analyze_aggregation_complexity() {
let selector = create_test_selector();
let empty_complexity = selector.analyze_aggregation_complexity(&[]);
assert_eq!(empty_complexity, 0.0);
use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
use linkrs_core::types::expr::Expression;
use linkrs_core::value::Value;
let ctx = ExpressionAnalysisContext::new();
let expr = Expression::Literal(Value::Int(42));
let id = ctx.register_expression(linkrs_core::types::expr::ExpressionMeta::new(expr));
let simple_expr = ContextualExpression::new(id, std::sync::Arc::new(ctx));
let simple_complexity = selector.analyze_aggregation_complexity(&[simple_expr]);
assert!(simple_complexity >= 0.0);
}
#[test]
fn test_comprehensive_strategy_selection() {
let selector = create_test_selector();
let context = AggregateContext {
input_rows: 10000,
group_keys: vec!["category".to_string()],
agg_function_count: 2,
memory_limit: 0,
input_is_sorted: false,
sort_keys_match_group_keys: false,
is_deterministic: true,
complexity_score: 0,
table_name: None,
};
let decision = selector.select_strategy_comprehensive("test", &context, &[]);
assert!(decision.estimated_cost > 0.0);
assert!(decision.estimated_memory_bytes > 0);
}
#[test]
fn logical_walk_emits_aggregate_strategy_note() {
use crate::optimizer::cost::SelectivityEstimator;
use crate::optimizer::stats::StatsView;
use crate::planning::plan::logical::logical_nodes::access::LogicalScanVerticesNode;
use crate::planning::plan::logical::logical_nodes::operation::LogicalAggregateNode;
use crate::planning::plan::logical::LogicalNodeEnum;
use linkrs_core::types::operators::AggregateFunction;
let stats_manager = Arc::new(StatisticsManager::new());
let cost_calculator = Arc::new(CostCalculator::new(stats_manager.clone()));
let selector = AggregateStrategySelector::new(cost_calculator);
let selectivity = SelectivityEstimator::new(stats_manager.clone());
let stats = StatsView::new(&stats_manager, Some("test"));
let scan = LogicalNodeEnum::ScanVertices(LogicalScanVerticesNode {
id: 1,
space_id: 1,
space_name: "test".to_string(),
tag: Some("person".to_string()),
expression: None,
limit: None,
projected_properties: vec![],
index_hint: None,
estimated_cardinality: None,
output_var: None,
col_names: vec![],
column_types: vec![],
});
let ctx = std::sync::Arc::new(
linkrs_core::types::expr::expression_context::ExpressionAnalysisContext::new(),
);
let expr = linkrs_core::Expression::Variable("n.age".to_string());
let meta = linkrs_core::types::expr::ExpressionMeta::new(expr);
let id = ctx.register_expression(meta);
let ctx_expr = linkrs_core::types::expr::contextual::ContextualExpression::new(id, ctx);
let aggregate = LogicalNodeEnum::Aggregate(LogicalAggregateNode {
id: 2,
input: Some(Box::new(scan)),
group_key_exprs: vec![ctx_expr],
aggregation_functions: vec![AggregateFunction::Count],
aggregation_args: vec![],
aggregation_distinct: vec![],
aggregation_filters: vec![],
grouping_sets: vec![],
output_var: None,
col_names: vec![],
column_types: vec![],
});
let mut notes = Vec::new();
let rewritten = walk_aggregate_strategies_logical(
&aggregate,
&stats,
&selector,
&selectivity,
&mut notes,
);
assert!(matches!(rewritten, LogicalNodeEnum::Aggregate(_)));
assert_eq!(notes.len(), 1);
assert!(notes[0].starts_with("aggregate: strategy="));
assert!(notes[0].contains("est_rows="));
}
}