use std::sync::Arc;
use super::config::{PartitioningConfig, PartitioningDecision, PartitioningLayoutInfo};
use crate::optimizer::cost::config::DEFAULT_FANOUT;
use crate::optimizer::cost::SelectivityEstimator;
use crate::optimizer::stats::feedback::selectivity::SelectivitySnapshot;
use crate::optimizer::stats::StatsView;
use crate::planning::plan::core::nodes::base::plan_node_traits::{
MultipleInputNode, SingleInputNode,
};
use crate::planning::plan::{PartitionSource, PartitionSpec, PartitionStrategy, PlanNodeEnum};
use linkrs_core::types::expr::Expression;
#[derive(Debug, Clone)]
pub struct PartitioningPlanner {
config: PartitioningConfig,
selectivity: Option<Arc<SelectivityEstimator>>,
}
impl PartitioningPlanner {
pub fn new(config: PartitioningConfig) -> Self {
Self {
config,
selectivity: None,
}
}
pub fn with_selectivity(mut self, selectivity: Arc<SelectivityEstimator>) -> Self {
self.selectivity = Some(selectivity);
self
}
pub fn config(&self) -> &PartitioningConfig {
&self.config
}
fn estimate_filtered_rows(
&self,
chain: &[&PlanNodeEnum],
rows: u64,
tag: &str,
statistics: &StatsView,
snapshot: &SelectivitySnapshot,
) -> u64 {
let Some(selectivity) = &self.selectivity else {
return rows;
};
let mut combined = 1.0f64;
let mut trusted = false;
for node in chain.iter().rev().skip(1) {
match node {
PlanNodeEnum::ExpandAll(_) => break,
PlanNodeEnum::Filter(filter) => {
let Some(meta) = filter.condition().expression() else {
continue;
};
let mut properties = Vec::new();
collect_condition_properties(meta.inner(), &mut properties);
let backed = properties
.iter()
.any(|property| statistics.property_stats(Some(tag), property).is_some());
if !backed {
continue;
}
combined *= selectivity.estimate_readonly(
statistics.space(),
meta.inner(),
Some(tag),
snapshot,
);
trusted = true;
}
_ => {}
}
}
if !trusted {
return rows;
}
(rows as f64 * combined.clamp(0.0, 1.0)).clamp(1.0, rows as f64) as u64
}
fn cost_gate_reason(&self, work: u64, output_rows: u64, partitions: usize) -> Option<String> {
let fixed = self.config.fixed_overhead_rows;
let per_partition = self.config.per_partition_rows;
let per_mille = self.config.exchange_row_per_mille;
if fixed == 0 && per_partition == 0 && per_mille == 0 {
return None;
}
let count = partitions as u64;
let saving = work.saturating_sub(work / count.max(1));
let exchange = output_rows.saturating_mul(per_mille as u64) / 1000;
let overhead = fixed
.saturating_add(count.saturating_mul(per_partition))
.saturating_add(exchange);
if saving > overhead {
None
} else {
Some(format!(
"parallel cost ({overhead}) exceeds the serial saving ({saving}) \
for {work} rows over {partitions} partitions"
))
}
}
fn layout_signature_with_layout(
&self,
source: &PartitionSource,
strategy: &PartitionStrategy,
layout: &PartitioningLayoutInfo,
) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
if layout.layout_version != 0 {
layout.layout_version.hash(&mut hasher);
} else {
match &self.config.vertex_id_range {
Some(range) => {
range.start.hash(&mut hasher);
range.end.hash(&mut hasher);
}
None => {
hasher.write_u8(0);
}
}
}
self.config.min_rows_per_partition.hash(&mut hasher);
self.config.max_partitions.hash(&mut hasher);
self.config.max_workers.hash(&mut hasher);
source.to_string().hash(&mut hasher);
strategy.to_string().hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
pub(crate) fn layout_signature(&self, source: &PartitionSource) -> u64 {
self.layout_signature_with_layout(
source,
&PartitionStrategy::Range,
&PartitioningLayoutInfo::default(),
)
}
pub fn decide(&self, root: &PlanNodeEnum, statistics: &StatsView) -> PartitioningDecision {
self.decide_with_layout(root, statistics, &PartitioningLayoutInfo::default())
}
pub fn decide_with_layout(
&self,
root: &PlanNodeEnum,
statistics: &StatsView,
layout: &PartitioningLayoutInfo,
) -> PartitioningDecision {
if !self.config.enabled {
return Self::fallback("partitioning is disabled");
}
if self.config.max_partitions < 2 {
return Self::fallback("partitioning max_partitions is less than two");
}
let range = match layout
.vertex_id_range
.clone()
.or_else(|| self.config.vertex_id_range.clone())
{
Some(range) => range,
None => {
return Self::fallback(
"no self-proven vertex-id range is available (storage evidence or config)",
)
}
};
if range.start >= range.end {
return Self::fallback("vertex-id range is empty");
}
let snapshot = self
.selectivity
.as_ref()
.map(|estimator| estimator.snapshot())
.unwrap_or_default();
if Self::has_write_operation(root) {
return Self::fallback("plan contains write operations; partitioning not supported");
}
if Self::has_transaction_boundary(root) {
return Self::fallback(
"plan crosses a transaction boundary; partitioning not supported",
);
}
if Self::has_graph_traversal(root) {
if let Some(decision) =
self.decide_anchored_traversal(root, statistics, &range, layout, &snapshot)
{
return decision;
}
return Self::fallback(
"plan contains recursive graph traversal; partitioning not supported",
);
}
let mut scans = Vec::new();
Self::collect_vertex_scans(root, &mut scans);
if scans.len() == 1 {
let Some(tag) = scans[0].tag() else {
return Self::fallback("vertex scan has no tag statistics key");
};
let rows = statistics.vertex_count(tag);
if rows == 0 {
return Self::fallback(format!(
"missing statistics for vertex tag '{tag}'; cannot estimate row count"
));
}
let threshold = self.config.min_rows_per_partition.saturating_mul(2);
if rows < threshold {
return Self::fallback(format!(
"estimated vertex rows ({rows}) are below the partition threshold"
));
}
let mut chain = Vec::new();
let work = if Self::collect_vertex_chain(root, &mut chain) {
let filtered =
self.estimate_filtered_rows(&chain, rows, tag, statistics, &snapshot);
if filtered < threshold {
return Self::fallback(format!(
"estimated filtered rows ({filtered}) are below the partition threshold"
));
}
filtered
} else {
rows
};
let desired = self.desired_partition_count(work);
if let Some(reason) = self.cost_gate_reason(work, work, desired) {
return Self::fallback(reason);
}
let ranges = split_range(&range, desired);
let source = PartitionSource::VertexId {
tag: tag.to_string(),
};
let layout_version =
self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
match PartitionSpec::try_new(
ranges,
source,
Some(layout_version),
) {
Ok(spec) => PartitioningDecision {
partition_spec: Some(spec),
reason: format!(
"partitioned tagged vertex scan '{}' into {} ranges from trusted layout",
tag, desired
),
},
Err(error) => {
Self::fallback(format!("invalid configured partition layout: {error}"))
}
}
} else if scans.is_empty() {
self.decide_edge_scan(root, statistics, &range, layout)
} else {
self.decide_multi_scan(root, statistics, &range, layout, &snapshot)
}
}
fn decide_multi_scan(
&self,
root: &PlanNodeEnum,
statistics: &StatsView,
range: &std::ops::Range<i64>,
layout: &PartitioningLayoutInfo,
snapshot: &SelectivitySnapshot,
) -> PartitioningDecision {
let Some((left, right, kind)) = Self::split_independent_branches(root) else {
return Self::fallback(
"multi-scan plan is not a union/cross-join/equality-join of independent scan branches",
);
};
let mut left_chain = Vec::new();
let mut right_chain = Vec::new();
if !Self::collect_vertex_chain(left, &mut left_chain)
|| !Self::collect_vertex_chain(right, &mut right_chain)
{
return Self::fallback(
"multi-scan branches are not linear chains ending in tagged vertex scans",
);
}
let PlanNodeEnum::ScanVertices(left_scan) = left_chain[left_chain.len() - 1] else {
return Self::fallback("left branch must end in a tagged vertex scan");
};
let PlanNodeEnum::ScanVertices(right_scan) = right_chain[right_chain.len() - 1] else {
return Self::fallback("right branch must end in a tagged vertex scan");
};
let Some(left_tag) = left_scan.tag() else {
return Self::fallback("left vertex scan has no tag statistics key");
};
let Some(right_tag) = right_scan.tag() else {
return Self::fallback("right vertex scan has no tag statistics key");
};
let left_rows = statistics.vertex_count(left_tag);
let right_rows = statistics.vertex_count(right_tag);
if left_rows == 0 || right_rows == 0 {
return Self::fallback(format!(
"missing statistics for vertex tag(s) '{left_tag}'/'{right_tag}'"
));
}
let threshold = self.config.min_rows_per_partition.saturating_mul(2);
let left_filtered =
self.estimate_filtered_rows(&left_chain, left_rows, left_tag, statistics, snapshot);
let right_filtered =
self.estimate_filtered_rows(&right_chain, right_rows, right_tag, statistics, snapshot);
let work = left_filtered.max(right_filtered);
if work < threshold {
return Self::fallback(format!(
"estimated filtered rows ({left_filtered}/{right_filtered}) are below the partition threshold"
));
}
let desired = self.desired_partition_count(work);
let output_rows = left_filtered.saturating_add(right_filtered);
if let Some(reason) = self.cost_gate_reason(work, output_rows, desired) {
return Self::fallback(reason);
}
let ranges = split_range(range, desired);
let representative = left_tag.to_string();
let source = PartitionSource::VertexId {
tag: representative,
};
let strategy = Self::multi_scan_join_strategy(root);
let layout_version = self.layout_signature_with_layout(&source, &strategy, layout);
let build = |strategy: &PartitionStrategy| match strategy {
PartitionStrategy::Range => {
PartitionSpec::try_new(ranges.clone(), source.clone(), Some(layout_version)).map(
|spec| PartitioningDecision {
partition_spec: Some(spec),
reason: format!(
"partitioned {kind} '{left_tag}'/'{right_tag}' into {desired} shared ranges"
),
},
)
}
PartitionStrategy::Hash { key } => PartitionSpec::try_new_hash(
key.clone(),
ranges.clone(),
source.clone(),
Some(layout_version),
)
.map(|spec| PartitioningDecision {
partition_spec: Some(spec),
reason: format!(
"hash-partitioned {kind} '{left_tag}'/'{right_tag}' by key '{key}' \
into {desired} buckets"
),
}),
PartitionStrategy::RoundRobin => {
unreachable!("multi-scan decisions never emit round-robin layouts")
}
};
match build(&strategy) {
Ok(decision) => decision,
Err(error) => Self::fallback(format!("invalid configured partition layout: {error}")),
}
}
fn multi_scan_join_strategy(root: &PlanNodeEnum) -> PartitionStrategy {
const DEFAULT_KEY: &str = "vid";
let PlanNodeEnum::InnerJoin(join) = root else {
return PartitionStrategy::Range;
};
if !Self::equality_join_keys_are_simple(join.hash_keys(), join.probe_keys()) {
return PartitionStrategy::Range;
}
if Self::keys_reference_vid(join.hash_keys()) && Self::keys_reference_vid(join.probe_keys())
{
return PartitionStrategy::Range;
}
let key = join
.hash_keys()
.first()
.and_then(|k| k.expression())
.and_then(|meta| match meta.inner() {
linkrs_core::types::expr::Expression::Variable(name) => Some(name.clone()),
_ => None,
})
.unwrap_or_else(|| DEFAULT_KEY.to_string());
PartitionStrategy::Hash { key }
}
fn keys_reference_vid(
keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
) -> bool {
!keys.is_empty()
&& keys.iter().all(|key| {
key.expression().is_some_and(|meta| {
matches!(
meta.inner(),
linkrs_core::types::expr::Expression::Variable(name)
if name == "vid" || name.ends_with(".vid")
)
})
})
}
fn decide_anchored_traversal(
&self,
root: &PlanNodeEnum,
statistics: &StatsView,
range: &std::ops::Range<i64>,
layout: &PartitioningLayoutInfo,
snapshot: &SelectivitySnapshot,
) -> Option<PartitioningDecision> {
let mut chain = Vec::new();
if !Self::collect_anchored_chain(root, &mut chain) {
return None;
}
let expand_count = chain
.iter()
.filter(|n| matches!(n, PlanNodeEnum::ExpandAll(_)))
.count();
if expand_count == 0 {
return Some(Self::fallback(
"anchored traversal must contain at least one ExpandAll hop",
));
}
let hops_ok = chain.iter().all(|n| match n {
PlanNodeEnum::ExpandAll(expand) => {
expand.filter().is_none()
&& expand.step_limit().unwrap_or(1) == 1
&& (expand.id_only() || expand.count_only())
}
_ => true,
});
if !hops_ok {
return Some(Self::fallback(
"anchored traversal hops must be filter-free and de-materialized (id_only/count_only); other columnar hops run unpartitioned",
));
}
let PlanNodeEnum::ScanVertices(scan) = chain[chain.len() - 1] else {
return Some(Self::fallback(
"anchored traversal must end in a tagged vertex scan",
));
};
let Some(tag) = scan.tag() else {
return Some(Self::fallback(
"anchor vertex scan has no tag statistics key",
));
};
let rows = statistics.vertex_count(tag);
if rows == 0 {
return Some(Self::fallback(format!(
"missing statistics for anchor tag '{tag}'; cannot estimate row count"
)));
}
let threshold = self.config.min_rows_per_partition.saturating_mul(2);
if rows < threshold {
return Some(Self::fallback(format!(
"estimated anchor rows ({rows}) are below the partition threshold"
)));
}
let filtered = self.estimate_filtered_rows(&chain, rows, tag, statistics, snapshot);
let fanout = chain
.iter()
.filter_map(|node| match node {
PlanNodeEnum::ExpandAll(expand) => {
Some(hop_fanout(statistics, expand.edge_types()))
}
_ => None,
})
.fold(1u64, |accumulated, hop| {
accumulated.saturating_mul(hop).max(1)
});
let work = filtered.saturating_mul(fanout);
if work < threshold {
return Some(Self::fallback(format!(
"estimated expanded rows ({work}) are below the partition threshold"
)));
}
let desired = self.desired_partition_count(work);
if let Some(reason) = self.cost_gate_reason(work, work, desired) {
return Some(Self::fallback(reason));
}
let ranges = split_range(range, desired);
let source = PartitionSource::VertexId {
tag: tag.to_string(),
};
let layout_version =
self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
match PartitionSpec::try_new(ranges, source, Some(layout_version)) {
Ok(spec) => Some(PartitioningDecision {
partition_spec: Some(spec),
reason: format!(
"partitioned anchored traversal by '{tag}' vertex-id ranges into {desired} partitions"
),
}),
Err(error) => Some(Self::fallback(format!(
"invalid configured partition layout: {error}"
))),
}
}
fn collect_anchored_chain<'a>(
node: &'a PlanNodeEnum,
chain: &mut Vec<&'a PlanNodeEnum>,
) -> bool {
chain.push(node);
match node {
PlanNodeEnum::ScanVertices(_) => true,
PlanNodeEnum::Filter(filter) => Self::collect_anchored_chain(filter.input(), chain),
PlanNodeEnum::Project(project) => Self::collect_anchored_chain(project.input(), chain),
PlanNodeEnum::Flatten(flatten) => Self::collect_anchored_chain(flatten.input(), chain),
PlanNodeEnum::Limit(limit) => Self::collect_anchored_chain(limit.input(), chain),
PlanNodeEnum::Sort(sort) => Self::collect_anchored_chain(sort.input(), chain),
PlanNodeEnum::Aggregate(agg) => Self::collect_anchored_chain(agg.input(), chain),
PlanNodeEnum::TopN(topn) => Self::collect_anchored_chain(topn.input(), chain),
PlanNodeEnum::Dedup(dedup) => Self::collect_anchored_chain(dedup.input(), chain),
PlanNodeEnum::Window(window) => Self::collect_anchored_chain(window.input(), chain),
PlanNodeEnum::ExpandAll(expand_all) => {
if let Some(input) = expand_all.inputs().first() {
Self::collect_anchored_chain(input, chain)
} else {
false
}
}
_ => false,
}
}
fn split_independent_branches(
node: &PlanNodeEnum,
) -> Option<(&PlanNodeEnum, &PlanNodeEnum, &'static str)> {
match node {
PlanNodeEnum::Union(union) => Some((union.input(), union.union_input(), "union")),
PlanNodeEnum::Minus(minus) => Some((minus.input(), minus.minus_input(), "minus")),
PlanNodeEnum::Intersect(intersect) => {
Some((intersect.input(), intersect.intersect_input(), "intersect"))
}
PlanNodeEnum::CrossJoin(join) => {
Some((join.left_input(), join.right_input(), "cross join"))
}
PlanNodeEnum::InnerJoin(join) => {
if Self::equality_join_keys_are_simple(join.hash_keys(), join.probe_keys()) {
Some((join.left_input(), join.right_input(), "equality join"))
} else {
None
}
}
_ => None,
}
}
fn equality_join_keys_are_simple(
hash_keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
probe_keys: &[linkrs_core::types::expr::contextual::ContextualExpression],
) -> bool {
hash_keys.len() == 1
&& probe_keys.len() == 1
&& hash_keys
.first()
.and_then(|k| k.expression())
.is_some_and(|m| {
matches!(m.inner(), linkrs_core::types::expr::Expression::Variable(_))
})
&& probe_keys
.first()
.and_then(|k| k.expression())
.is_some_and(|m| {
matches!(m.inner(), linkrs_core::types::expr::Expression::Variable(_))
})
}
fn collect_vertex_chain<'a>(node: &'a PlanNodeEnum, chain: &mut Vec<&'a PlanNodeEnum>) -> bool {
chain.push(node);
match node {
PlanNodeEnum::ScanVertices(_) => true,
PlanNodeEnum::Filter(filter) => Self::collect_vertex_chain(filter.input(), chain),
PlanNodeEnum::Project(project) => Self::collect_vertex_chain(project.input(), chain),
PlanNodeEnum::Limit(limit) => Self::collect_vertex_chain(limit.input(), chain),
PlanNodeEnum::Sort(sort) => Self::collect_vertex_chain(sort.input(), chain),
PlanNodeEnum::Aggregate(agg) => Self::collect_vertex_chain(agg.input(), chain),
PlanNodeEnum::TopN(topn) => Self::collect_vertex_chain(topn.input(), chain),
PlanNodeEnum::Dedup(dedup) => Self::collect_vertex_chain(dedup.input(), chain),
PlanNodeEnum::Window(window) => Self::collect_vertex_chain(window.input(), chain),
_ => false,
}
}
fn desired_partition_count(&self, rows: u64) -> usize {
let by_rows = usize::try_from(rows / self.config.min_rows_per_partition)
.unwrap_or(self.config.max_partitions);
let by_workers = self.config.max_workers;
by_rows
.clamp(2, self.config.max_partitions)
.min(by_workers.max(2))
}
fn decide_edge_scan(
&self,
root: &PlanNodeEnum,
statistics: &StatsView,
range: &std::ops::Range<i64>,
layout: &PartitioningLayoutInfo,
) -> PartitioningDecision {
let mut chain = Vec::new();
if !Self::collect_chain(root, &mut chain) {
return Self::fallback(
"edge scan plan is not a linear chain ending in a ScanEdges node",
);
}
let PlanNodeEnum::ScanEdges(scan) = chain[chain.len() - 1] else {
return Self::fallback(
"edge scan plan must end in a ScanEdges node for partition selection",
);
};
let Some(edge_type) = scan.edge_type() else {
return Self::fallback("edge scan has no edge type statistics key");
};
let rows = statistics.edge_count(&edge_type);
if rows == 0 {
return Self::fallback(format!(
"missing statistics for edge type '{edge_type}'; cannot estimate row count"
));
}
if rows < self.config.min_rows_per_partition.saturating_mul(2) {
return Self::fallback(format!(
"estimated edge rows ({rows}) are below the partition threshold"
));
}
let desired = self.desired_partition_count(rows);
if let Some(reason) = self.cost_gate_reason(rows, rows, desired) {
return Self::fallback(reason);
}
let ranges = split_range(range, desired);
let source = PartitionSource::EdgeId {
edge_type: edge_type.to_string(),
};
let layout_version =
self.layout_signature_with_layout(&source, &PartitionStrategy::Range, layout);
match PartitionSpec::try_new(ranges, source, Some(layout_version)) {
Ok(spec) => {
let description = spec.source().to_string();
PartitioningDecision {
partition_spec: Some(spec),
reason: format!(
"partitioned edge scan {description} into {desired} src-id ranges from trusted layout"
),
}
}
Err(error) => Self::fallback(format!("invalid configured partition layout: {error}")),
}
}
fn collect_chain<'a>(node: &'a PlanNodeEnum, chain: &mut Vec<&'a PlanNodeEnum>) -> bool {
chain.push(node);
match node {
PlanNodeEnum::ScanVertices(_) | PlanNodeEnum::ScanEdges(_) => true,
PlanNodeEnum::Filter(filter) => Self::collect_chain(filter.input(), chain),
PlanNodeEnum::Project(project) => Self::collect_chain(project.input(), chain),
PlanNodeEnum::Limit(limit) => Self::collect_chain(limit.input(), chain),
PlanNodeEnum::Sort(sort) => Self::collect_chain(sort.input(), chain),
PlanNodeEnum::Aggregate(agg) => Self::collect_chain(agg.input(), chain),
PlanNodeEnum::TopN(topn) => Self::collect_chain(topn.input(), chain),
PlanNodeEnum::Dedup(dedup) => Self::collect_chain(dedup.input(), chain),
PlanNodeEnum::Window(window) => Self::collect_chain(window.input(), chain),
_ => false,
}
}
fn collect_vertex_scans<'a>(
node: &'a PlanNodeEnum,
scans: &mut Vec<&'a crate::planning::plan::core::nodes::ScanVerticesNode>,
) {
if let PlanNodeEnum::ScanVertices(scan) = node {
scans.push(scan);
}
for child in node.children() {
Self::collect_vertex_scans(child, scans);
}
}
fn has_write_operation(node: &PlanNodeEnum) -> bool {
matches!(
node,
PlanNodeEnum::CopyFrom(_)
| PlanNodeEnum::CopyTo(_)
| PlanNodeEnum::InsertVertices(_)
| PlanNodeEnum::InsertEdges(_)
| PlanNodeEnum::DeleteVertices(_)
| PlanNodeEnum::DeleteEdges(_)
| PlanNodeEnum::DeleteIndex(_)
| PlanNodeEnum::PipeDeleteVertices(_)
| PlanNodeEnum::PipeDeleteEdges(_)
| PlanNodeEnum::Update(_)
) || node.children().iter().any(|c| Self::has_write_operation(c))
}
fn has_transaction_boundary(node: &PlanNodeEnum) -> bool {
matches!(
node,
PlanNodeEnum::BeginTransaction(_) | PlanNodeEnum::Commit(_) | PlanNodeEnum::Rollback(_)
) || node
.children()
.iter()
.any(|c| Self::has_transaction_boundary(c))
}
fn has_graph_traversal(node: &PlanNodeEnum) -> bool {
matches!(
node,
PlanNodeEnum::ExpandAll(_)
| PlanNodeEnum::Traverse(_)
| PlanNodeEnum::AppendVertices(_)
| PlanNodeEnum::BiTraverse(_)
| PlanNodeEnum::Loop(_)
| PlanNodeEnum::RecursiveCte(_)
| PlanNodeEnum::MultiShortestPath(_)
| PlanNodeEnum::BFSShortest(_)
| PlanNodeEnum::AllPaths(_)
| PlanNodeEnum::ShortestPath(_)
) || node.children().iter().any(|c| Self::has_graph_traversal(c))
}
fn fallback(reason: impl Into<String>) -> PartitioningDecision {
PartitioningDecision {
partition_spec: None,
reason: reason.into(),
}
}
}
fn hop_fanout(statistics: &StatsView, edge_types: &[String]) -> u64 {
edge_types
.iter()
.filter_map(|edge_type| statistics.edge_stats(edge_type))
.map(|stats| {
stats
.avg_out_degree
.max(stats.avg_in_degree)
.round()
.max(0.0) as u64
})
.max()
.filter(|fanout| *fanout > 0)
.unwrap_or(DEFAULT_FANOUT)
}
fn collect_condition_properties(expr: &Expression, out: &mut Vec<String>) {
match expr {
Expression::Property { property, .. } => out.push(property.clone()),
Expression::Binary { left, right, .. } => {
collect_condition_properties(left, out);
collect_condition_properties(right, out);
}
Expression::Unary { operand, .. } => collect_condition_properties(operand, out),
Expression::Function { args, .. } => {
for arg in args {
collect_condition_properties(arg.as_expr(), out);
}
}
_ => {}
}
}
fn split_range(range: &std::ops::Range<i64>, partition_count: usize) -> Vec<std::ops::Range<i64>> {
let total = range.end - range.start;
let width = (total + partition_count as i64 - 1) / partition_count as i64;
let mut ranges = Vec::with_capacity(partition_count);
for index in 0..partition_count {
let start = range.start + (index as i64) * width;
if start >= range.end {
break;
}
ranges.push(start..(start + width).min(range.end));
}
ranges
}