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#[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 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 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 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 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 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 strategy.to_string().hash(&mut hasher);
167 hasher.finish()
168 }
169
170 #[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 pub fn decide(&self, root: &PlanNodeEnum, statistics: &StatsView) -> PartitioningDecision {
186 self.decide_with_layout(root, statistics, &PartitioningLayoutInfo::default())
187 }
188
189 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 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 let snapshot = self
224 .selectivity
225 .as_ref()
226 .map(|estimator| estimator.snapshot())
227 .unwrap_or_default();
228
229 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
878fn 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
899fn 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}