Skip to main content

linkrs_query/planning/join_order/
plan_join_order.rs

1//! DP join order enumeration framework.
2//!
3//! [`JoinOrderEnumerator`] prices base table scans, then fills the DP table
4//! level by level from small subgraphs to the full query graph. Exact
5//! enumeration tries every `(left, right)` split up to
6//! [`MAX_LEVEL_TO_PLAN_EXACTLY`]; larger levels only try `leftLevel == 1`
7//! splits to avoid the combinatorial explosion. Binary joins live here; the
8//! WCO intersect hook (`plan_wco_join`) plugs into the exact path.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use linkrs_core::types::expr::contextual::ContextualExpression;
14use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
15use linkrs_core::types::expr::ExpressionMeta;
16use linkrs_core::Expression;
17
18use crate::planning::plan::core::next_node_id;
19use crate::planning::plan::logical::logical_nodes::access::{
20    LogicalScanEdgesNode, LogicalScanVerticesNode,
21};
22use crate::planning::plan::logical::logical_nodes::join::LogicalInnerJoinNode;
23use crate::planning::plan::logical::LogicalNodeEnum;
24
25use super::cardinality_estimator::{CardinalityEstimator, JoinOrderStats};
26use super::cost_model::CostModel;
27use super::join_tree::{JoinHint, JoinHintError, JoinTreeConstructor};
28use super::query_graph::QueryGraph;
29use super::subplans_table::{JoinOrderPlan, SubPlansTable};
30use super::subquery_graph::SubqueryGraph;
31
32/// Exact enumeration is used up to this level; larger levels use the
33/// approximate (left-deep with level-1 right side) strategy.
34pub const MAX_LEVEL_TO_PLAN_EXACTLY: usize = 7;
35
36/// Mutable DP state threaded through enumeration.
37pub struct JoinOrderEnumeratorContext {
38    pub sub_plans_table: SubPlansTable,
39    pub cardinality_estimator: CardinalityEstimator,
40    pub cost_model: CostModel,
41    pub max_cost: u64,
42    space_id: u64,
43    space_name: String,
44    expr_ctx: Arc<ExpressionAnalysisContext>,
45    key_cache: HashMap<String, ContextualExpression>,
46}
47
48impl std::fmt::Debug for JoinOrderEnumeratorContext {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("JoinOrderEnumeratorContext")
51            .field("max_cost", &self.max_cost)
52            .field("num_dp_levels", &self.sub_plans_table.num_levels())
53            .finish()
54    }
55}
56
57impl Default for JoinOrderEnumeratorContext {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl JoinOrderEnumeratorContext {
64    pub fn new() -> Self {
65        Self {
66            sub_plans_table: SubPlansTable::new(),
67            cardinality_estimator: CardinalityEstimator::new(),
68            cost_model: CostModel,
69            max_cost: u64::MAX,
70            space_id: 1,
71            space_name: "default".to_string(),
72            expr_ctx: Arc::new(ExpressionAnalysisContext::new()),
73            key_cache: HashMap::new(),
74        }
75    }
76
77    /// Space the base scans belong to (mirrors the MATCH planning context).
78    pub fn with_space(mut self, space_id: u64, space_name: impl Into<String>) -> Self {
79        self.space_id = space_id;
80        self.space_name = space_name.into();
81        self
82    }
83
84    /// Attach real cardinality statistics to the estimator.
85    pub fn with_stats(mut self, stats: JoinOrderStats) -> Self {
86        self.cardinality_estimator.set_stats(stats);
87        self
88    }
89
90    /// Intern a variable join key so both sides of a hash join share one
91    /// expression identity.
92    pub fn join_key(&mut self, variable: &str) -> ContextualExpression {
93        if let Some(cached) = self.key_cache.get(variable) {
94            return cached.clone();
95        }
96        let id = self
97            .expr_ctx
98            .register_expression(ExpressionMeta::new(Expression::Variable(
99                variable.to_string(),
100            )));
101        let key = ContextualExpression::new(id, Arc::clone(&self.expr_ctx));
102        self.key_cache.insert(variable.to_string(), key.clone());
103        key
104    }
105}
106
107/// DP join order enumerator.
108#[derive(Debug, Default)]
109pub struct JoinOrderEnumerator {
110    context: JoinOrderEnumeratorContext,
111}
112
113impl JoinOrderEnumerator {
114    pub fn new() -> Self {
115        Self {
116            context: JoinOrderEnumeratorContext::new(),
117        }
118    }
119
120    /// Space the base scans belong to.
121    pub fn with_space(mut self, space_id: u64, space_name: impl Into<String>) -> Self {
122        self.context = std::mem::take(&mut self.context).with_space(space_id, space_name);
123        self
124    }
125
126    /// Attach real cardinality statistics to the estimator.
127    pub fn with_stats(mut self, stats: JoinOrderStats) -> Self {
128        self.context = std::mem::take(&mut self.context).with_stats(stats);
129        self
130    }
131
132    pub fn context(&self) -> &JoinOrderEnumeratorContext {
133        &self.context
134    }
135
136    pub(crate) fn context_mut(&mut self) -> &mut JoinOrderEnumeratorContext {
137        &mut self.context
138    }
139
140    /// Seed base scans and fill the DP table level by level, so both
141    /// automatic enumeration and hint solving reuse fully priced entries.
142    fn enumerate_levels(&mut self, query_graph: &Arc<QueryGraph>) {
143        self.plan_base_table_scans(query_graph);
144        let max_level = query_graph.num_nodes() + query_graph.num_rels();
145        for level in 2..=max_level {
146            if level <= MAX_LEVEL_TO_PLAN_EXACTLY {
147                self.plan_level_exactly(level, query_graph);
148            } else {
149                self.plan_level_approximately(level, query_graph);
150            }
151        }
152    }
153
154    /// Enumerate the join order for a query graph and return the cheapest
155    /// plan covering every node and rel, if one exists.
156    pub fn plan_query_graph(&mut self, query_graph: &Arc<QueryGraph>) -> Option<LogicalNodeEnum> {
157        self.enumerate_levels(query_graph);
158        let mut full = SubqueryGraph::new(Arc::clone(query_graph));
159        for node_pos in 0..query_graph.num_nodes() {
160            full.add_query_node(node_pos);
161        }
162        for rel_pos in 0..query_graph.num_rels() {
163            full.add_query_rel(rel_pos);
164        }
165        self.context
166            .sub_plans_table
167            .get_best_plan(&full)
168            .map(|p| p.plan.clone())
169    }
170
171    /// Seed level-1 plans: one node scan per query node, one rel scan per
172    /// query rel.
173    ///
174    /// Base scans carry the interned variable as `expression` so their
175    /// factorized schemas are non-empty and downstream `encode_plan`
176    /// can distinguish flat/unflat variants.
177    fn plan_base_table_scans(&mut self, query_graph: &Arc<QueryGraph>) {
178        for (node_pos, node) in query_graph.query_nodes.iter().enumerate() {
179            let key = self.context.join_key(&node.variable);
180            let cardinality = self
181                .context
182                .cardinality_estimator
183                .estimate_node_scan(&node.labels);
184            self.context
185                .cardinality_estimator
186                .set_node_id_domain(key.id().clone(), cardinality);
187            let plan = LogicalNodeEnum::ScanVertices(LogicalScanVerticesNode {
188                id: next_node_id(),
189                space_id: self.context.space_id,
190                space_name: self.context.space_name.clone(),
191                tag: node.labels.first().cloned(),
192                expression: Some(key),
193                limit: None,
194                projected_properties: vec![],
195                index_hint: None,
196                estimated_cardinality: Some(cardinality),
197                output_var: Some(node.variable.clone()),
198                col_names: vec![node.variable.clone()],
199                column_types: vec![],
200            });
201            let cost = CostModel::compute_extend_cost(0, cardinality);
202            let encoding = self.encode_plan(&plan, query_graph);
203            let subgraph = SubqueryGraph::single_node(query_graph, node_pos);
204            self.context.sub_plans_table.add_plan(
205                &subgraph,
206                JoinOrderPlan::new(plan, cost, cardinality, encoding),
207            );
208        }
209        for (rel_pos, rel) in query_graph.query_rels.iter().enumerate() {
210            let key = self.context.join_key(&rel.variable);
211            let cardinality = self
212                .context
213                .cardinality_estimator
214                .estimate_rel_scan(&rel.edge_types);
215            let plan = LogicalNodeEnum::ScanEdges(LogicalScanEdgesNode {
216                id: next_node_id(),
217                space_id: self.context.space_id,
218                edge_type: rel.edge_types.first().cloned(),
219                expression: Some(key),
220                limit: None,
221                projected_properties: vec![],
222                index_hint: None,
223                estimated_cardinality: Some(cardinality),
224                output_var: Some(rel.variable.clone()),
225                col_names: vec![rel.variable.clone()],
226                column_types: vec![],
227            });
228            let cost = CostModel::compute_extend_cost(0, cardinality);
229            let encoding = self.encode_plan(&plan, query_graph);
230            let subgraph = SubqueryGraph::single_rel(query_graph, rel_pos);
231            self.context.sub_plans_table.add_plan(
232                &subgraph,
233                JoinOrderPlan::new(plan, cost, cardinality, encoding),
234            );
235        }
236    }
237
238    /// Encode the factorization structure of a candidate plan as a bitset.
239    ///
240    /// Bits `0..64` record node flatness directly: bit `i` is set when the
241    /// `i`-th query node is flat in the plan schema. Plans with different
242    /// encodings are kept as separate DP candidates because flatness changes
243    /// downstream flatten costs.
244    ///
245    /// Truncation policy (explicit fallback): only the first 64 nodes map to
246    /// dedicated bits. Overflow nodes (`i >= 64`) and all rel variables are
247    /// folded into the same `u64` via a deterministic mixing step, so very
248    /// large patterns never panic but may collide. Collisions only merge DP
249    /// candidates; they never change row semantics.
250    pub(crate) fn encode_plan(
251        &mut self,
252        plan: &LogicalNodeEnum,
253        query_graph: &Arc<QueryGraph>,
254    ) -> u64 {
255        let mut owned = plan.clone();
256        // `compute_schema_for_plan` returns `Result` (the factorized
257        // at-most-one-unflat invariant is now a recoverable error, not a
258        // panic). A failed schema means this candidate cannot be factorized;
259        // degrade to an all-unflat encoding instead of crashing. The fallback
260        // only merges DP candidates (never changes row semantics); the warn
261        // keeps the degrade visible instead of silent.
262        let schema = match Self::compute_schema_for_plan(&mut owned) {
263            Ok(s) => s,
264            Err(e) => {
265                log::warn!("encode_plan: schema error {e}; degrade to all-unflat encoding");
266                crate::planning::plan::factorization::FactorizedSchema::new()
267            }
268        };
269        let mut encoding = 0u64;
270        for (i, node) in query_graph.query_nodes.iter().enumerate() {
271            if !Self::variable_is_flat(&schema, &mut self.context, &node.variable) {
272                continue;
273            }
274            if i < 64 {
275                encoding |= 1u64 << i;
276            } else {
277                encoding = Self::mix_overflow(encoding, i as u64);
278            }
279        }
280        for (j, rel) in query_graph.query_rels.iter().enumerate() {
281            if !Self::variable_is_flat(&schema, &mut self.context, &rel.variable) {
282                continue;
283            }
284            // Rel flatness is folded rather than given dedicated bits: rels
285            // share the 64-bit space with overflow nodes via mixing.
286            encoding = Self::mix_overflow(encoding, 0x9e37u64 + j as u64);
287        }
288        encoding
289    }
290
291    /// Exact enumeration: try every `(left_level, right_level)` split.
292    fn plan_level_exactly(&mut self, level: usize, query_graph: &Arc<QueryGraph>) {
293        let max_left = level / 2;
294        for left_level in 1..=max_left.max(1).min(level - 1) {
295            let right_level = level - left_level;
296            if left_level > 1 {
297                self.plan_wco_join(left_level, right_level, query_graph);
298            }
299            self.plan_inner_join(left_level, right_level, query_graph);
300        }
301    }
302
303    /// Approximate enumeration: only `leftLevel == 1` splits.
304    fn plan_level_approximately(&mut self, level: usize, query_graph: &Arc<QueryGraph>) {
305        if level < 2 {
306            return;
307        }
308        self.plan_inner_join(1, level - 1, query_graph);
309    }
310
311    /// Binary inner joins between adjacent left/right subgraphs.
312    fn plan_inner_join(
313        &mut self,
314        left_level: usize,
315        right_level: usize,
316        query_graph: &Arc<QueryGraph>,
317    ) {
318        let right_subgraphs: Vec<SubqueryGraph> = self
319            .context
320            .sub_plans_table
321            .get_subgraphs(right_level)
322            .iter()
323            .map(|(sg, _)| sg.clone())
324            .collect();
325        for right_subgraph in &right_subgraphs {
326            let neighbors = right_subgraph.get_neighbor_subgraphs(left_level);
327            for nbr_subgraph in &neighbors {
328                if !nbr_subgraph.is_joinable_with(right_subgraph) {
329                    continue;
330                }
331                // Prefer index-nested-loop extends for pure extend pairs;
332                // fall back to hash joins on shared nodes.
333                if self.try_plan_extend_join(right_subgraph, nbr_subgraph, query_graph) {
334                    continue;
335                }
336                self.plan_inner_hash_join(right_subgraph, nbr_subgraph, query_graph);
337            }
338        }
339    }
340
341    /// Extend join for pairs without shared nodes but with a rel-to-node
342    /// step in either direction. Returns false when the pair needs a hash
343    /// join instead.
344    fn try_plan_extend_join(
345        &mut self,
346        right_subgraph: &SubqueryGraph,
347        nbr_subgraph: &SubqueryGraph,
348        query_graph: &Arc<QueryGraph>,
349    ) -> bool {
350        if right_subgraph.is_connected(nbr_subgraph) {
351            return false;
352        }
353        if !right_subgraph.is_extendable_with(nbr_subgraph) {
354            return false;
355        }
356        let right_plans: Vec<JoinOrderPlan> = self
357            .context
358            .sub_plans_table
359            .get_plans(right_subgraph)
360            .to_vec();
361        let nbr_plans: Vec<JoinOrderPlan> = self
362            .context
363            .sub_plans_table
364            .get_plans(nbr_subgraph)
365            .to_vec();
366        if right_plans.is_empty() || nbr_plans.is_empty() {
367            return false;
368        }
369        let mut combined = right_subgraph.clone();
370        combined.add_subquery_graph(nbr_subgraph);
371        for right_plan in &right_plans {
372            for nbr_plan in &nbr_plans {
373                let (plan, cost, cardinality) =
374                    self.append_extend(right_plan, nbr_plan, query_graph);
375                let encoding = self.encode_plan(&plan, query_graph);
376                self.context.sub_plans_table.add_plan(
377                    &combined,
378                    JoinOrderPlan::new(plan, cost, cardinality, encoding),
379                );
380            }
381        }
382        true
383    }
384
385    /// Hash join on the shared node positions of two connected subgraphs.
386    fn plan_inner_hash_join(
387        &mut self,
388        right_subgraph: &SubqueryGraph,
389        nbr_subgraph: &SubqueryGraph,
390        query_graph: &Arc<QueryGraph>,
391    ) {
392        if !right_subgraph.is_connected(nbr_subgraph) {
393            return;
394        }
395        let right_plans: Vec<JoinOrderPlan> = self
396            .context
397            .sub_plans_table
398            .get_plans(right_subgraph)
399            .to_vec();
400        let nbr_plans: Vec<JoinOrderPlan> = self
401            .context
402            .sub_plans_table
403            .get_plans(nbr_subgraph)
404            .to_vec();
405        if right_plans.is_empty() || nbr_plans.is_empty() {
406            return;
407        }
408        let mut combined = right_subgraph.clone();
409        combined.add_subquery_graph(nbr_subgraph);
410        for right_plan in &right_plans {
411            for nbr_plan in &nbr_plans {
412                let (plan, cost, cardinality) = self.append_inner_hash_join(
413                    right_plan,
414                    nbr_plan,
415                    right_subgraph,
416                    nbr_subgraph,
417                    query_graph,
418                );
419                let encoding = self.encode_plan(&plan, query_graph);
420                self.context.sub_plans_table.add_plan(
421                    &combined,
422                    JoinOrderPlan::new(plan, cost, cardinality, encoding),
423                );
424            }
425        }
426    }
427
428    /// Build a hash join node with one key per shared node variable.
429    pub(crate) fn append_inner_hash_join(
430        &mut self,
431        probe: &JoinOrderPlan,
432        build: &JoinOrderPlan,
433        probe_subgraph: &SubqueryGraph,
434        build_subgraph: &SubqueryGraph,
435        query_graph: &Arc<QueryGraph>,
436    ) -> (LogicalNodeEnum, u64, u64) {
437        let shared = probe_subgraph.get_connected_node_positions(build_subgraph);
438        let mut hash_keys = Vec::new();
439        let mut probe_keys = Vec::new();
440        let mut domains = Vec::new();
441        for node_pos in &shared {
442            if let Some(node) = query_graph.query_nodes.get(*node_pos) {
443                let key = self.context.join_key(&node.variable);
444                domains.push(
445                    self.context
446                        .cardinality_estimator
447                        .get_node_id_domain(key.id()),
448                );
449                hash_keys.push(key.clone());
450                probe_keys.push(key);
451            }
452        }
453        let cardinality = self.context.cardinality_estimator.estimate_hash_join(
454            probe.cardinality,
455            build.cardinality,
456            &domains,
457        );
458        let join_key_card = domains.into_iter().min().unwrap_or(cardinality);
459        // Charge the emitted rows on top of the probe/build scan price:
460        // the executor materializes every output row (hash table plus
461        // downstream rescans), and pricing scans alone lets deep binary
462        // chains hide intermediate blowup. Without this, honestly priced
463        // WCO intersects could never win automatic cost competition on
464        // cyclic queries.
465        let cost = CostModel::compute_hash_join_cost(
466            probe.cost,
467            probe.cardinality,
468            build.cost,
469            join_key_card,
470        )
471        .saturating_add(cardinality);
472        let mut col_names = probe.plan.col_names().to_vec();
473        for name in build.plan.col_names() {
474            if !col_names.contains(name) {
475                col_names.push(name.clone());
476            }
477        }
478        let plan = LogicalNodeEnum::InnerJoin(LogicalInnerJoinNode {
479            id: next_node_id(),
480            left: Box::new(probe.plan.clone()),
481            right: Box::new(build.plan.clone()),
482            hash_keys,
483            probe_keys,
484            recommended_algorithm: None,
485            output_var: None,
486            col_names,
487            column_types: vec![],
488        });
489        (plan, cost, cardinality)
490    }
491
492    /// Build an extend join node (empty keys: nested-loop expansion at
493    /// execution time) priced as an extend of the probe side. The fanout
494    /// uses the statistics average-degree hint capped by the build
495    /// cardinality, so skewed real graphs price extends correctly.
496    fn append_extend(
497        &mut self,
498        probe: &JoinOrderPlan,
499        build: &JoinOrderPlan,
500        _query_graph: &Arc<QueryGraph>,
501    ) -> (LogicalNodeEnum, u64, u64) {
502        let fanout = build
503            .cardinality
504            .min(self.context.cardinality_estimator.avg_degree_hint());
505        let cardinality = self
506            .context
507            .cardinality_estimator
508            .estimate_extend(probe.cardinality, fanout);
509        // Charge the emitted rows as well: a keyless extend buffers the
510        // build side and writes every fanned-out row, so pricing the probe
511        // scan alone hides the blowup (e.g. a triangle extend chain emitting
512        // millions of rows for a few hundred thousand cost units) and prices
513        // WCO intersects out of automatic selection.
514        let cost = CostModel::compute_extend_cost(probe.cost, probe.cardinality)
515            .saturating_add(build.cost)
516            .saturating_add(cardinality);
517        let mut col_names = probe.plan.col_names().to_vec();
518        for name in build.plan.col_names() {
519            if !col_names.contains(name) {
520                col_names.push(name.clone());
521            }
522        }
523        let plan = LogicalNodeEnum::InnerJoin(LogicalInnerJoinNode {
524            id: next_node_id(),
525            left: Box::new(probe.plan.clone()),
526            right: Box::new(build.plan.clone()),
527            hash_keys: vec![],
528            probe_keys: vec![],
529            recommended_algorithm: None,
530            output_var: None,
531            col_names,
532            column_types: vec![],
533        });
534        (plan, cost, cardinality)
535    }
536
537    /// Plan a query graph following a user join hint.
538    ///
539    /// The hint is resolved to a [`JoinTree`](super::join_tree::JoinTree)
540    /// and then solved bottom-up against the enumerated DP table: leaf
541    /// scans reuse the cheapest covering entry (a rel scan prefers the
542    /// rel-plus-endpoints plan), binary nodes become hash joins on the
543    /// shared nodes, and multi-way nodes become `WcoIntersect` via
544    /// `append_intersect`. Hints normally arrive from `USING JOIN`
545    /// clauses; see [`JoinHint::from_ast`].
546    pub fn plan_with_hint(
547        &mut self,
548        query_graph: &Arc<QueryGraph>,
549        hint: &JoinHint,
550    ) -> Result<LogicalNodeEnum, JoinHintError> {
551        // Enumerate first so hint leaves reuse fully priced DP entries
552        // (e.g. a rel scan covering its endpoints for binary rel joins).
553        self.enumerate_levels(query_graph);
554        let tree = JoinTreeConstructor::construct(query_graph, hint)?;
555        let (plan, _subgraph, _cost, _card) = self.solve_tree_node(&tree.root, query_graph)?;
556        Ok(plan)
557    }
558}
559
560mod dp;
561mod encoding;
562#[cfg(test)]
563mod tests;