Skip to main content

linkrs_query/optimizer/partitioning/
planner.rs

1use std::sync::Arc;
2
3use super::config::{PartitioningConfig, PartitioningDecision, PartitioningLayoutInfo};
4use crate::optimizer::cost::config::DEFAULT_FANOUT;
5use crate::optimizer::cost::SelectivityEstimator;
6use crate::optimizer::stats::feedback::selectivity::SelectivitySnapshot;
7use crate::optimizer::stats::StatsView;
8use crate::planning::plan::core::nodes::base::plan_node_traits::{
9    MultipleInputNode, SingleInputNode,
10};
11use crate::planning::plan::{PartitionSource, PartitionSpec, PartitionStrategy, PlanNodeEnum};
12use linkrs_core::types::expr::Expression;
13
14/// Chooses a partition layout only for a single tagged vertex scan. More
15/// complex source topologies retain the existing single-tree path until they
16/// have an explicit source-domain mapping in the physical planner.
17#[derive(Debug, Clone)]
18pub struct PartitioningPlanner {
19    config: PartitioningConfig,
20    selectivity: Option<Arc<SelectivityEstimator>>,
21}
22
23impl PartitioningPlanner {
24    pub fn new(config: PartitioningConfig) -> Self {
25        Self {
26            config,
27            selectivity: None,
28        }
29    }
30
31    /// Attach the shared selectivity estimator so partition decisions can
32    /// weigh post-filter row counts. Without it every decision falls back to
33    /// the table-total behavior it had before selectivity awareness existed.
34    pub fn with_selectivity(mut self, selectivity: Arc<SelectivityEstimator>) -> Self {
35        self.selectivity = Some(selectivity);
36        self
37    }
38
39    pub fn config(&self) -> &PartitioningConfig {
40        &self.config
41    }
42
43    /// Estimate the rows surviving the filters on an anchor chain.
44    ///
45    /// The chain runs root-first and ends in the anchor scan. Filters below
46    /// the first graph expansion filter anchor rows, so each partition does
47    /// less work; filters above an expansion apply after fan-out and do not
48    /// shrink the per-partition scan, so estimation stops there. A filter is
49    /// only trusted when at least one property it references has collected
50    /// statistics — purely heuristic estimates keep the previous behavior
51    /// instead of flipping plans on guesses.
52    fn estimate_filtered_rows(
53        &self,
54        chain: &[&PlanNodeEnum],
55        rows: u64,
56        tag: &str,
57        statistics: &StatsView,
58        snapshot: &SelectivitySnapshot,
59    ) -> u64 {
60        let Some(selectivity) = &self.selectivity else {
61            return rows;
62        };
63        let mut combined = 1.0f64;
64        let mut trusted = false;
65        for node in chain.iter().rev().skip(1) {
66            match node {
67                PlanNodeEnum::ExpandAll(_) => break,
68                PlanNodeEnum::Filter(filter) => {
69                    let Some(meta) = filter.condition().expression() else {
70                        continue;
71                    };
72                    let mut properties = Vec::new();
73                    collect_condition_properties(meta.inner(), &mut properties);
74                    let backed = properties
75                        .iter()
76                        .any(|property| statistics.property_stats(Some(tag), property).is_some());
77                    if !backed {
78                        continue;
79                    }
80                    // Read-only estimate through the decision snapshot: the
81                    // recording entry point would register conditions and
82                    // refresh baselines, making identical plans decide
83                    // differently depending on planning order.
84                    combined *= selectivity.estimate_readonly(
85                        statistics.space(),
86                        meta.inner(),
87                        Some(tag),
88                        snapshot,
89                    );
90                    trusted = true;
91                }
92                _ => {}
93            }
94        }
95        if !trusted {
96            return rows;
97        }
98        (rows as f64 * combined.clamp(0.0, 1.0)).clamp(1.0, rows as f64) as u64
99    }
100
101    /// Economic gate: partition only when the parallel saving exceeds the
102    /// fixed plus exchange overhead, all in row-equivalents. Returns a
103    /// fallback reason when serial wins. All-zero cost params disable the
104    /// comparison and leave the row-count threshold as the only gate.
105    fn cost_gate_reason(&self, work: u64, output_rows: u64, partitions: usize) -> Option<String> {
106        let fixed = self.config.fixed_overhead_rows;
107        let per_partition = self.config.per_partition_rows;
108        let per_mille = self.config.exchange_row_per_mille;
109        if fixed == 0 && per_partition == 0 && per_mille == 0 {
110            return None;
111        }
112        let count = partitions as u64;
113        let saving = work.saturating_sub(work / count.max(1));
114        let exchange = output_rows.saturating_mul(per_mille as u64) / 1000;
115        let overhead = fixed
116            .saturating_add(count.saturating_mul(per_partition))
117            .saturating_add(exchange);
118        if saving > overhead {
119            None
120        } else {
121            Some(format!(
122                "parallel cost ({overhead}) exceeds the serial saving ({saving}) \
123                 for {work} rows over {partitions} partitions"
124            ))
125        }
126    }
127
128    /// Deterministic layout signature for the current partitioning config and
129    /// data domain.  It is embedded in the [`PartitionSpec::layout_version`]
130    /// so the plan cache fingerprint changes whenever the trusted vertex-id
131    /// range, partition granularity, data domain, or the storage's monotonic
132    /// layout version changes — forcing a replan instead of reusing a stale
133    /// cached partition layout.
134    ///
135    /// When the storage provides a real monotonic layout version, it replaces
136    /// the config-range component of the signature (the storage layout is the
137    /// authoritative data-domain witness); otherwise the configured range is
138    /// hashed as before.
139    fn layout_signature_with_layout(
140        &self,
141        source: &PartitionSource,
142        strategy: &PartitionStrategy,
143        layout: &PartitioningLayoutInfo,
144    ) -> u64 {
145        use std::hash::{Hash, Hasher};
146        let mut hasher = std::collections::hash_map::DefaultHasher::new();
147        if layout.layout_version != 0 {
148            layout.layout_version.hash(&mut hasher);
149        } else {
150            match &self.config.vertex_id_range {
151                Some(range) => {
152                    range.start.hash(&mut hasher);
153                    range.end.hash(&mut hasher);
154                }
155                None => {
156                    hasher.write_u8(0);
157                }
158            }
159        }
160        self.config.min_rows_per_partition.hash(&mut hasher);
161        self.config.max_partitions.hash(&mut hasher);
162        self.config.max_workers.hash(&mut hasher);
163        source.to_string().hash(&mut hasher);
164        // The distribution strategy participates in the signature so a
165        // strategy switch invalidates cached plans that embed the old spec.
166        strategy.to_string().hash(&mut hasher);
167        hasher.finish()
168    }
169
170    /// Config-only layout signature (no storage layout information).
171    #[cfg(test)]
172    pub(crate) fn layout_signature(&self, source: &PartitionSource) -> u64 {
173        self.layout_signature_with_layout(
174            source,
175            &PartitionStrategy::Range,
176            &PartitioningLayoutInfo::default(),
177        )
178    }
179
180    /// Decide using only the static configuration (config-range fallback).
181    ///
182    /// Convenience entry used by tests and callers without storage layout
183    /// information; the pipeline passes [`PartitioningLayoutInfo`] via
184    /// [`decide_with_layout`](Self::decide_with_layout).
185    pub fn decide(&self, root: &PlanNodeEnum, statistics: &StatsView) -> PartitioningDecision {
186        self.decide_with_layout(root, statistics, &PartitioningLayoutInfo::default())
187    }
188
189    /// Decide using storage-provided layout information (self-proven
190    /// vertex-id domain and monotonic layout version).
191    pub fn decide_with_layout(
192        &self,
193        root: &PlanNodeEnum,
194        statistics: &StatsView,
195        layout: &PartitioningLayoutInfo,
196    ) -> PartitioningDecision {
197        if !self.config.enabled {
198            return Self::fallback("partitioning is disabled");
199        }
200        if self.config.max_partitions < 2 {
201            return Self::fallback("partitioning max_partitions is less than two");
202        }
203        // The storage self-proven domain wins; the configured range is a
204        // fallback for setups that explicitly trust a caller-supplied range.
205        let range = match layout
206            .vertex_id_range
207            .clone()
208            .or_else(|| self.config.vertex_id_range.clone())
209        {
210            Some(range) => range,
211            None => {
212                return Self::fallback(
213                    "no self-proven vertex-id range is available (storage evidence or config)",
214                )
215            }
216        };
217        if range.start >= range.end {
218            return Self::fallback("vertex-id range is empty");
219        }
220
221        // One snapshot per decision: every estimate below reads it, so the
222        // decision is self-consistent and never mutates shared feedback.
223        let snapshot = self
224            .selectivity
225            .as_ref()
226            .map(|estimator| estimator.snapshot())
227            .unwrap_or_default();
228
229        // Reject plans with unsupported node categories.
230        if Self::has_write_operation(root) {
231            return Self::fallback("plan contains write operations; partitioning not supported");
232        }
233        if Self::has_transaction_boundary(root) {
234            return Self::fallback(
235                "plan crosses a transaction boundary; partitioning not supported",
236            );
237        }
238        if Self::has_graph_traversal(root) {
239            // E4: allow an anchored *bounded* traversal (a single ExpandAll
240            // above the anchor vertex scan). The anchor is partitioned by
241            // vertex-id range and each partition expands locally; only
242            // recursive / path-algorithm traversals are rejected outright.
243            if let Some(decision) =
244                self.decide_anchored_traversal(root, statistics, &range, layout, &snapshot)
245            {
246                return decision;
247            }
248            return Self::fallback(
249                "plan contains recursive graph traversal; partitioning not supported",
250            );
251        }
252
253        let mut scans = Vec::new();
254        Self::collect_vertex_scans(root, &mut scans);
255        if scans.len() == 1 {
256            let Some(tag) = scans[0].tag() else {
257                return Self::fallback("vertex scan has no tag statistics key");
258            };
259            let rows = statistics.vertex_count(tag);
260            if rows == 0 {
261                return Self::fallback(format!(
262                    "missing statistics for vertex tag '{tag}'; cannot estimate row count"
263                ));
264            }
265            let threshold = self.config.min_rows_per_partition.saturating_mul(2);
266            if rows < threshold {
267                return Self::fallback(format!(
268                    "estimated vertex rows ({rows}) are below the partition threshold"
269                ));
270            }
271            let mut chain = Vec::new();
272            // Size partitions by the rows that actually flow through them,
273            // not the table total: a filter just above the gate must not cut
274            // the same partition count as the unfiltered table.
275            let work = if Self::collect_vertex_chain(root, &mut chain) {
276                let filtered =
277                    self.estimate_filtered_rows(&chain, rows, tag, statistics, &snapshot);
278                if filtered < threshold {
279                    return Self::fallback(format!(
280                        "estimated filtered rows ({filtered}) are below the partition threshold"
281                    ));
282                }
283                filtered
284            } else {
285                rows
286            };
287
288            let desired = self.desired_partition_count(work);
289            if let Some(reason) = self.cost_gate_reason(work, work, desired) {
290                return Self::fallback(reason);
291            }
292            let ranges = split_range(&range, desired);
293            let source = PartitionSource::VertexId {
294                tag: tag.to_string(),
295            };
296            let layout_version =
297                self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
298            match PartitionSpec::try_new(
299                ranges,
300                source,
301                // Layout version is a signature of the partitioning config
302                // and data domain; the storage layer may supply a real
303                // monotonic version in a later phase.
304                Some(layout_version),
305            ) {
306                Ok(spec) => PartitioningDecision {
307                    partition_spec: Some(spec),
308                    reason: format!(
309                        "partitioned tagged vertex scan '{}' into {} ranges from trusted layout",
310                        tag, desired
311                    ),
312                },
313                Err(error) => {
314                    Self::fallback(format!("invalid configured partition layout: {error}"))
315                }
316            }
317        } else if scans.is_empty() {
318            self.decide_edge_scan(root, statistics, &range, layout)
319        } else {
320            self.decide_multi_scan(root, statistics, &range, layout, &snapshot)
321        }
322    }
323
324    /// Choose a partition layout for a plan with several independent tagged
325    /// vertex scans (UNION / MINUS / INTERSECT / cross-join / equality join
326    /// of partition-local scan chains). Every branch shares the same
327    /// vertex-id ranges; each branch is scanned independently per partition
328    /// and gathered before the global set/join operator runs.
329    ///
330    /// For equality joins (E1b): when both sides scan the same vertex tag,
331    /// they are co-partitioned and the join runs partition-locally without
332    /// a hash exchange.  When sides scan different tags, a hash exchange
333    /// aligns partitions by the join key.
334    fn decide_multi_scan(
335        &self,
336        root: &PlanNodeEnum,
337        statistics: &StatsView,
338        range: &std::ops::Range<i64>,
339        layout: &PartitioningLayoutInfo,
340        snapshot: &SelectivitySnapshot,
341    ) -> PartitioningDecision {
342        let Some((left, right, kind)) = Self::split_independent_branches(root) else {
343            return Self::fallback(
344                "multi-scan plan is not a union/cross-join/equality-join of independent scan branches",
345            );
346        };
347        let mut left_chain = Vec::new();
348        let mut right_chain = Vec::new();
349        if !Self::collect_vertex_chain(left, &mut left_chain)
350            || !Self::collect_vertex_chain(right, &mut right_chain)
351        {
352            return Self::fallback(
353                "multi-scan branches are not linear chains ending in tagged vertex scans",
354            );
355        }
356        let PlanNodeEnum::ScanVertices(left_scan) = left_chain[left_chain.len() - 1] else {
357            return Self::fallback("left branch must end in a tagged vertex scan");
358        };
359        let PlanNodeEnum::ScanVertices(right_scan) = right_chain[right_chain.len() - 1] else {
360            return Self::fallback("right branch must end in a tagged vertex scan");
361        };
362        let Some(left_tag) = left_scan.tag() else {
363            return Self::fallback("left vertex scan has no tag statistics key");
364        };
365        let Some(right_tag) = right_scan.tag() else {
366            return Self::fallback("right vertex scan has no tag statistics key");
367        };
368
369        let left_rows = statistics.vertex_count(left_tag);
370        let right_rows = statistics.vertex_count(right_tag);
371        if left_rows == 0 || right_rows == 0 {
372            return Self::fallback(format!(
373                "missing statistics for vertex tag(s) '{left_tag}'/'{right_tag}'"
374            ));
375        }
376        let threshold = self.config.min_rows_per_partition.saturating_mul(2);
377        let left_filtered =
378            self.estimate_filtered_rows(&left_chain, left_rows, left_tag, statistics, snapshot);
379        let right_filtered =
380            self.estimate_filtered_rows(&right_chain, right_rows, right_tag, statistics, snapshot);
381        // Branches run independently under one shared layout and gather
382        // afterwards, so the dominant branch decides: a tiny branch must not
383        // veto partitioning that pays off for the branch holding the work.
384        // (Co-partitioned joins stay correct regardless of branch sizes —
385        // fragment pairing only needs the shared layout, not balanced rows.)
386        // There is intentionally no per-branch raw gate: without a trusted
387        // filter the filtered estimate degrades to the raw count, so the
388        // single dominant-branch gate below subsumes it.
389        let work = left_filtered.max(right_filtered);
390        if work < threshold {
391            return Self::fallback(format!(
392                "estimated filtered rows ({left_filtered}/{right_filtered}) are below the partition threshold"
393            ));
394        }
395
396        let desired = self.desired_partition_count(work);
397        let output_rows = left_filtered.saturating_add(right_filtered);
398        if let Some(reason) = self.cost_gate_reason(work, output_rows, desired) {
399            return Self::fallback(reason);
400        }
401        let ranges = split_range(range, desired);
402        let representative = left_tag.to_string();
403        let source = PartitionSource::VertexId {
404            tag: representative,
405        };
406
407        // Q4: classify the join key domain. Keys that reference the
408        // vertex-id partition key are co-partitionable by id ranges
409        // (Range). Any other simple variable key cannot be mapped onto the
410        // id domain, so the plan declares a hash distribution contract and
411        // the physical builder aligns rows via its RepartitionHash exchange.
412        let strategy = Self::multi_scan_join_strategy(root);
413
414        let layout_version = self.layout_signature_with_layout(&source, &strategy, layout);
415        let build = |strategy: &PartitionStrategy| match strategy {
416            PartitionStrategy::Range => {
417                PartitionSpec::try_new(ranges.clone(), source.clone(), Some(layout_version)).map(
418                    |spec| PartitioningDecision {
419                        partition_spec: Some(spec),
420                        reason: format!(
421                    "partitioned {kind} '{left_tag}'/'{right_tag}' into {desired} shared ranges"
422                ),
423                    },
424                )
425            }
426            PartitionStrategy::Hash { key } => PartitionSpec::try_new_hash(
427                key.clone(),
428                ranges.clone(),
429                source.clone(),
430                Some(layout_version),
431            )
432            .map(|spec| PartitioningDecision {
433                partition_spec: Some(spec),
434                reason: format!(
435                    "hash-partitioned {kind} '{left_tag}'/'{right_tag}' by key '{key}' \
436                         into {desired} buckets"
437                ),
438            }),
439            PartitionStrategy::RoundRobin => {
440                unreachable!("multi-scan decisions never emit round-robin layouts")
441            }
442        };
443        match build(&strategy) {
444            Ok(decision) => decision,
445            Err(error) => Self::fallback(format!("invalid configured partition layout: {error}")),
446        }
447    }
448
449    /// Distribution strategy for a multi-scan plan root.
450    ///
451    /// Equality joins whose keys all reference the vertex-id partition key
452    /// keep the range co-partitioning layout; joins on any other simple
453    /// variable key get a `Hash { key }` layout keyed by the first hash-key
454    /// variable.
455    fn multi_scan_join_strategy(root: &PlanNodeEnum) -> PartitionStrategy {
456        const DEFAULT_KEY: &str = "vid";
457        let PlanNodeEnum::InnerJoin(join) = root else {
458            return PartitionStrategy::Range;
459        };
460        if !Self::equality_join_keys_are_simple(join.hash_keys(), join.probe_keys()) {
461            return PartitionStrategy::Range;
462        }
463        if Self::keys_reference_vid(join.hash_keys()) && Self::keys_reference_vid(join.probe_keys())
464        {
465            return PartitionStrategy::Range;
466        }
467        let key = join
468            .hash_keys()
469            .first()
470            .and_then(|k| k.expression())
471            .and_then(|meta| match meta.inner() {
472                linkrs_core::types::expr::Expression::Variable(name) => Some(name.clone()),
473                _ => None,
474            })
475            .unwrap_or_else(|| DEFAULT_KEY.to_string());
476        PartitionStrategy::Hash { key }
477    }
478
479    /// Whether every key expression references the vertex-id partition key.
480    fn keys_reference_vid(
481        keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
482    ) -> bool {
483        !keys.is_empty()
484            && keys.iter().all(|key| {
485                key.expression().is_some_and(|meta| {
486                    matches!(
487                        meta.inner(),
488                        linkrs_core::types::expr::Expression::Variable(name)
489                            if name == "vid" || name.ends_with(".vid")
490                    )
491                })
492            })
493    }
494
495    /// Choose a partition layout for an anchored bounded traversal (E4):
496    /// a linear chain of `ExpandAll` hops above the anchor vertex
497    /// scan. The anchor is partitioned by vertex-id range; every partition
498    /// runs the bounded traversal locally over its anchor subrange and the
499    /// results are gathered globally. Recursive traversals and path
500    /// algorithms are not chain-walkable and fall through to rejection.
501    ///
502    /// Returns `None` when the plan is not an anchored bounded traversal, so
503    /// the caller can fall back to the generic graph-traversal rejection.
504    fn decide_anchored_traversal(
505        &self,
506        root: &PlanNodeEnum,
507        statistics: &StatsView,
508        range: &std::ops::Range<i64>,
509        layout: &PartitioningLayoutInfo,
510        snapshot: &SelectivitySnapshot,
511    ) -> Option<PartitioningDecision> {
512        let mut chain = Vec::new();
513        if !Self::collect_anchored_chain(root, &mut chain) {
514            return None;
515        }
516        // chain is root-first, ending with the anchor scan.
517        let expand_count = chain
518            .iter()
519            .filter(|n| matches!(n, PlanNodeEnum::ExpandAll(_)))
520            .count();
521        if expand_count == 0 {
522            return Some(Self::fallback(
523                "anchored traversal must contain at least one ExpandAll hop",
524            ));
525        }
526        // C1: every ExpandAll hop must be de-materialized (id_only/count_only)
527        // and filter-free, so each partition runs the full bounded chain over
528        // its anchor subrange without changing row semantics. Other columnar
529        // hops stay on the non-partitioned path by design.
530        let hops_ok = chain.iter().all(|n| match n {
531            PlanNodeEnum::ExpandAll(expand) => {
532                expand.filter().is_none()
533                    && expand.step_limit().unwrap_or(1) == 1
534                    && (expand.id_only() || expand.count_only())
535            }
536            _ => true,
537        });
538        if !hops_ok {
539            return Some(Self::fallback(
540                "anchored traversal hops must be filter-free and de-materialized (id_only/count_only); other columnar hops run unpartitioned",
541            ));
542        }
543        let PlanNodeEnum::ScanVertices(scan) = chain[chain.len() - 1] else {
544            return Some(Self::fallback(
545                "anchored traversal must end in a tagged vertex scan",
546            ));
547        };
548        let Some(tag) = scan.tag() else {
549            return Some(Self::fallback(
550                "anchor vertex scan has no tag statistics key",
551            ));
552        };
553        let rows = statistics.vertex_count(tag);
554        if rows == 0 {
555            return Some(Self::fallback(format!(
556                "missing statistics for anchor tag '{tag}'; cannot estimate row count"
557            )));
558        }
559        let threshold = self.config.min_rows_per_partition.saturating_mul(2);
560        if rows < threshold {
561            return Some(Self::fallback(format!(
562                "estimated anchor rows ({rows}) are below the partition threshold"
563            )));
564        }
565        let filtered = self.estimate_filtered_rows(&chain, rows, tag, statistics, snapshot);
566        // Each partition expands its own anchor slice, so the gate input is
567        // the expanded work, not the anchor count: a small anchor with a
568        // high fanout still pays off partitioned.
569        let fanout = chain
570            .iter()
571            .filter_map(|node| match node {
572                PlanNodeEnum::ExpandAll(expand) => {
573                    Some(hop_fanout(statistics, expand.edge_types()))
574                }
575                _ => None,
576            })
577            .fold(1u64, |accumulated, hop| {
578                accumulated.saturating_mul(hop).max(1)
579            });
580        let work = filtered.saturating_mul(fanout);
581        if work < threshold {
582            return Some(Self::fallback(format!(
583                "estimated expanded rows ({work}) are below the partition threshold"
584            )));
585        }
586
587        let desired = self.desired_partition_count(work);
588        if let Some(reason) = self.cost_gate_reason(work, work, desired) {
589            return Some(Self::fallback(reason));
590        }
591        let ranges = split_range(range, desired);
592        let source = PartitionSource::VertexId {
593            tag: tag.to_string(),
594        };
595        let layout_version =
596            self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
597        match PartitionSpec::try_new(ranges, source, Some(layout_version)) {
598            Ok(spec) => Some(PartitioningDecision {
599                partition_spec: Some(spec),
600                reason: format!(
601                    "partitioned anchored traversal by '{tag}' vertex-id ranges into {desired} partitions"
602                ),
603            }),
604            Err(error) => Some(Self::fallback(format!(
605                "invalid configured partition layout: {error}"
606            ))),
607        }
608    }
609
610    /// Walk a linear chain that ends in a tagged vertex scan. `Flatten` is
611    /// row-preserving (it replays child rows), so it passes through like the
612    /// other non-graph operators. Any other graph operator (Traverse, Loop,
613    /// path algorithms, vertex-property fetches) fails the walk.
614    fn collect_anchored_chain<'a>(
615        node: &'a PlanNodeEnum,
616        chain: &mut Vec<&'a PlanNodeEnum>,
617    ) -> bool {
618        chain.push(node);
619        match node {
620            PlanNodeEnum::ScanVertices(_) => true,
621            PlanNodeEnum::Filter(filter) => Self::collect_anchored_chain(filter.input(), chain),
622            PlanNodeEnum::Project(project) => Self::collect_anchored_chain(project.input(), chain),
623            PlanNodeEnum::Flatten(flatten) => Self::collect_anchored_chain(flatten.input(), chain),
624            PlanNodeEnum::Limit(limit) => Self::collect_anchored_chain(limit.input(), chain),
625            PlanNodeEnum::Sort(sort) => Self::collect_anchored_chain(sort.input(), chain),
626            PlanNodeEnum::Aggregate(agg) => Self::collect_anchored_chain(agg.input(), chain),
627            PlanNodeEnum::TopN(topn) => Self::collect_anchored_chain(topn.input(), chain),
628            PlanNodeEnum::Dedup(dedup) => Self::collect_anchored_chain(dedup.input(), chain),
629            PlanNodeEnum::Window(window) => Self::collect_anchored_chain(window.input(), chain),
630            PlanNodeEnum::ExpandAll(expand_all) => {
631                if let Some(input) = expand_all.inputs().first() {
632                    Self::collect_anchored_chain(input, chain)
633                } else {
634                    false
635                }
636            }
637            _ => false,
638        }
639    }
640
641    /// Split a binary-op root into its two branch inputs when the op is a set
642    /// op, cross join, or equality join on the partition key (vertex-id).
643    fn split_independent_branches(
644        node: &PlanNodeEnum,
645    ) -> Option<(&PlanNodeEnum, &PlanNodeEnum, &'static str)> {
646        match node {
647            PlanNodeEnum::Union(union) => Some((union.input(), union.union_input(), "union")),
648            PlanNodeEnum::Minus(minus) => Some((minus.input(), minus.minus_input(), "minus")),
649            PlanNodeEnum::Intersect(intersect) => {
650                Some((intersect.input(), intersect.intersect_input(), "intersect"))
651            }
652            PlanNodeEnum::CrossJoin(join) => {
653                Some((join.left_input(), join.right_input(), "cross join"))
654            }
655            PlanNodeEnum::InnerJoin(join) => {
656                // E1b: allow equality join when the join key is a simple variable
657                // reference (i.e. the vertex-id partition key).  Complex join keys
658                // (expressions, composite keys) are rejected for now.
659                if Self::equality_join_keys_are_simple(join.hash_keys(), join.probe_keys()) {
660                    Some((join.left_input(), join.right_input(), "equality join"))
661                } else {
662                    None
663                }
664            }
665            _ => None,
666        }
667    }
668
669    /// Whether a join's hash/probe keys are each a single simple variable
670    /// reference (the only key shape the partitioned join path supports).
671    fn equality_join_keys_are_simple(
672        hash_keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
673        probe_keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
674    ) -> bool {
675        hash_keys.len() == 1
676            && probe_keys.len() == 1
677            && hash_keys
678                .first()
679                .and_then(|k| k.expression())
680                .is_some_and(|m| {
681                    matches!(m.inner(), linkrs_core::types::expr::Expression::Variable(_))
682                })
683            && probe_keys
684                .first()
685                .and_then(|k| k.expression())
686                .is_some_and(|m| {
687                    matches!(m.inner(), linkrs_core::types::expr::Expression::Variable(_))
688                })
689    }
690
691    /// Walk a linear chain that must end in a tagged vertex scan.
692    fn collect_vertex_chain<'a>(node: &'a PlanNodeEnum, chain: &mut Vec<&'a PlanNodeEnum>) -> bool {
693        chain.push(node);
694        match node {
695            PlanNodeEnum::ScanVertices(_) => true,
696            PlanNodeEnum::Filter(filter) => Self::collect_vertex_chain(filter.input(), chain),
697            PlanNodeEnum::Project(project) => Self::collect_vertex_chain(project.input(), chain),
698            PlanNodeEnum::Limit(limit) => Self::collect_vertex_chain(limit.input(), chain),
699            PlanNodeEnum::Sort(sort) => Self::collect_vertex_chain(sort.input(), chain),
700            PlanNodeEnum::Aggregate(agg) => Self::collect_vertex_chain(agg.input(), chain),
701            PlanNodeEnum::TopN(topn) => Self::collect_vertex_chain(topn.input(), chain),
702            PlanNodeEnum::Dedup(dedup) => Self::collect_vertex_chain(dedup.input(), chain),
703            PlanNodeEnum::Window(window) => Self::collect_vertex_chain(window.input(), chain),
704            _ => false,
705        }
706    }
707
708    /// Number of partitions to cut for a relation with `rows` estimated rows.
709    ///
710    /// E5 granularity tuning: the baseline is `rows / min_rows_per_partition`
711    /// (one partition per threshold bucket), but the result is additionally
712    /// bounded by the available worker threads (`max_workers`) so we never cut
713    /// more partitions than can run concurrently — the actual parallelism is
714    /// `min(partitions, workers)`, and surplus partitions only add exchange
715    /// overhead. Small relations are rejected earlier via the `2 * min_rows`
716    /// threshold; a zero-size relation here clamps to the minimum of 2.
717    fn desired_partition_count(&self, rows: u64) -> usize {
718        let by_rows = usize::try_from(rows / self.config.min_rows_per_partition)
719            .unwrap_or(self.config.max_partitions);
720        let by_workers = self.config.max_workers;
721        by_rows
722            .clamp(2, self.config.max_partitions)
723            .min(by_workers.max(2))
724    }
725
726    /// Choose an edge-scan partition layout for a pure edge-table chain.
727    ///
728    /// The plan must be a linear chain ending in a `ScanEdges` node whose rows
729    /// are self-sufficient (edge properties / aggregates only). Chains that
730    /// fetch src/dst vertex properties (Expand, GetProp, AppendVertices) are
731    /// rejected because partitioning by src-id range cannot provide the
732    /// vertex-side join key.
733    fn decide_edge_scan(
734        &self,
735        root: &PlanNodeEnum,
736        statistics: &StatsView,
737        range: &std::ops::Range<i64>,
738        layout: &PartitioningLayoutInfo,
739    ) -> PartitioningDecision {
740        let mut chain = Vec::new();
741        if !Self::collect_chain(root, &mut chain) {
742            return Self::fallback(
743                "edge scan plan is not a linear chain ending in a ScanEdges node",
744            );
745        }
746        let PlanNodeEnum::ScanEdges(scan) = chain[chain.len() - 1] else {
747            return Self::fallback(
748                "edge scan plan must end in a ScanEdges node for partition selection",
749            );
750        };
751        let Some(edge_type) = scan.edge_type() else {
752            return Self::fallback("edge scan has no edge type statistics key");
753        };
754        let rows = statistics.edge_count(&edge_type);
755        if rows == 0 {
756            return Self::fallback(format!(
757                "missing statistics for edge type '{edge_type}'; cannot estimate row count"
758            ));
759        }
760        if rows < self.config.min_rows_per_partition.saturating_mul(2) {
761            return Self::fallback(format!(
762                "estimated edge rows ({rows}) are below the partition threshold"
763            ));
764        }
765
766        let desired = self.desired_partition_count(rows);
767        if let Some(reason) = self.cost_gate_reason(rows, rows, desired) {
768            return Self::fallback(reason);
769        }
770        let ranges = split_range(range, desired);
771        let source = PartitionSource::EdgeId {
772            edge_type: edge_type.to_string(),
773        };
774        let layout_version =
775            self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
776        match PartitionSpec::try_new(ranges, source, Some(layout_version)) {
777            Ok(spec) => {
778                let description = spec.source().to_string();
779                PartitioningDecision {
780                    partition_spec: Some(spec),
781                    reason: format!(
782                        "partitioned edge scan {description} into {desired} src-id ranges from trusted layout"
783                    ),
784                }
785            }
786            Err(error) => Self::fallback(format!("invalid configured partition layout: {error}")),
787        }
788    }
789
790    /// Walk a linear chain from `node` down to its terminal scan. Supports the
791    /// same unary operators as the physical partition builder; anything else
792    /// (joins, Expand, vertex lookups, set ops) returns `false`.
793    fn collect_chain<'a>(node: &'a PlanNodeEnum, chain: &mut Vec<&'a PlanNodeEnum>) -> bool {
794        chain.push(node);
795        match node {
796            PlanNodeEnum::ScanVertices(_) | PlanNodeEnum::ScanEdges(_) => true,
797            PlanNodeEnum::Filter(filter) => Self::collect_chain(filter.input(), chain),
798            PlanNodeEnum::Project(project) => Self::collect_chain(project.input(), chain),
799            PlanNodeEnum::Limit(limit) => Self::collect_chain(limit.input(), chain),
800            PlanNodeEnum::Sort(sort) => Self::collect_chain(sort.input(), chain),
801            PlanNodeEnum::Aggregate(agg) => Self::collect_chain(agg.input(), chain),
802            PlanNodeEnum::TopN(topn) => Self::collect_chain(topn.input(), chain),
803            PlanNodeEnum::Dedup(dedup) => Self::collect_chain(dedup.input(), chain),
804            PlanNodeEnum::Window(window) => Self::collect_chain(window.input(), chain),
805            _ => false,
806        }
807    }
808
809    fn collect_vertex_scans<'a>(
810        node: &'a PlanNodeEnum,
811        scans: &mut Vec<&'a crate::planning::plan::core::nodes::ScanVerticesNode>,
812    ) {
813        if let PlanNodeEnum::ScanVertices(scan) = node {
814            scans.push(scan);
815        }
816        for child in node.children() {
817            Self::collect_vertex_scans(child, scans);
818        }
819    }
820
821    /// Returns true when the plan tree contains any write operation node.
822    fn has_write_operation(node: &PlanNodeEnum) -> bool {
823        matches!(
824            node,
825            PlanNodeEnum::CopyFrom(_)
826                | PlanNodeEnum::CopyTo(_)
827                | PlanNodeEnum::InsertVertices(_)
828                | PlanNodeEnum::InsertEdges(_)
829                | PlanNodeEnum::DeleteVertices(_)
830                | PlanNodeEnum::DeleteEdges(_)
831                | PlanNodeEnum::DeleteIndex(_)
832                | PlanNodeEnum::PipeDeleteVertices(_)
833                | PlanNodeEnum::PipeDeleteEdges(_)
834                | PlanNodeEnum::Update(_)
835        ) || node.children().iter().any(|c| Self::has_write_operation(c))
836    }
837
838    /// Returns true when the plan tree contains a transaction-control node.
839    fn has_transaction_boundary(node: &PlanNodeEnum) -> bool {
840        matches!(
841            node,
842            PlanNodeEnum::BeginTransaction(_) | PlanNodeEnum::Commit(_) | PlanNodeEnum::Rollback(_)
843        ) || node
844            .children()
845            .iter()
846            .any(|c| Self::has_transaction_boundary(c))
847    }
848
849    /// Returns true when the plan tree contains a recursive graph traversal
850    /// or path-algorithm node (single-hop `Expand`/`BiExpand` are
851    /// morsel-parallel and no longer block partitioning; only variable-length
852    /// `ExpandAll`/`Traverse` and path algorithms are considered graph
853    /// traversals for partition blocking).
854    fn has_graph_traversal(node: &PlanNodeEnum) -> bool {
855        matches!(
856            node,
857            PlanNodeEnum::ExpandAll(_)
858                | PlanNodeEnum::Traverse(_)
859                | PlanNodeEnum::AppendVertices(_)
860                | PlanNodeEnum::BiTraverse(_)
861                | PlanNodeEnum::Loop(_)
862                | PlanNodeEnum::RecursiveCte(_)
863                | PlanNodeEnum::MultiShortestPath(_)
864                | PlanNodeEnum::BFSShortest(_)
865                | PlanNodeEnum::AllPaths(_)
866                | PlanNodeEnum::ShortestPath(_)
867        ) || node.children().iter().any(|c| Self::has_graph_traversal(c))
868    }
869
870    fn fallback(reason: impl Into<String>) -> PartitioningDecision {
871        PartitioningDecision {
872            partition_spec: None,
873            reason: reason.into(),
874        }
875    }
876}
877
878/// Expansion fanout of one traversal hop for partition work estimates.
879///
880/// Takes the largest rounded average degree across the hop's edge types so a
881/// low first type cannot shrink the estimate, and falls back to the single
882/// no-statistics default shared with the row estimators.
883fn hop_fanout(statistics: &StatsView, edge_types: &[String]) -> u64 {
884    edge_types
885        .iter()
886        .filter_map(|edge_type| statistics.edge_stats(edge_type))
887        .map(|stats| {
888            stats
889                .avg_out_degree
890                .max(stats.avg_in_degree)
891                .round()
892                .max(0.0) as u64
893        })
894        .max()
895        .filter(|fanout| *fanout > 0)
896        .unwrap_or(DEFAULT_FANOUT)
897}
898
899/// Property names referenced anywhere inside a filter condition.
900fn collect_condition_properties(expr: &Expression, out: &mut Vec<String>) {
901    match expr {
902        Expression::Property { property, .. } => out.push(property.clone()),
903        Expression::Binary { left, right, .. } => {
904            collect_condition_properties(left, out);
905            collect_condition_properties(right, out);
906        }
907        Expression::Unary { operand, .. } => collect_condition_properties(operand, out),
908        Expression::Function { args, .. } => {
909            for arg in args {
910                collect_condition_properties(arg.as_expr(), out);
911            }
912        }
913        _ => {}
914    }
915}
916
917fn split_range(range: &std::ops::Range<i64>, partition_count: usize) -> Vec<std::ops::Range<i64>> {
918    let total = range.end - range.start;
919    let width = (total + partition_count as i64 - 1) / partition_count as i64;
920    let mut ranges = Vec::with_capacity(partition_count);
921    for index in 0..partition_count {
922        let start = range.start + (index as i64) * width;
923        if start >= range.end {
924            break;
925        }
926        ranges.push(start..(start + width).min(range.end));
927    }
928    ranges
929}