Skip to main content

linkrs_query/planning/plan/
partition_spec.rs

1//! Physical partition layout selected for a plan.
2//!
3//! Extracted from `execution_plan.rs` so that downstream consumers (the
4//! arena `PhysicalPlan`, the plan cache, the partitioned arena builder, the
5//! streaming partition view) no longer depend on `ExecutionPlan`
6//! module just to reach `PartitionSpec`.  The optimizer and cache — which
7//! sit upstream of the executor — also import from here, keeping the
8//! dependency direction flat (`planning::plan::partition_spec` has no
9//! intra-crate dependencies beyond `core`).
10
11use std::ops::Range;
12use std::{error::Error, fmt};
13
14/// Identifies the data domain that a partition layout maps ranges over.
15///
16/// This prevents the plan cache from reusing a stale `PartitionSpec` when the
17/// underlying storage layout has changed (e.g. re-indexing, new vertex tag).
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PartitionSource {
20    /// Ranges over a vertex-id space identified by a tag.
21    VertexId { tag: String },
22    /// Ranges over an edge-id space identified by an edge type.
23    EdgeId { edge_type: String },
24    /// Ranges over an explicit index's key space.
25    Index { index_name: String },
26}
27
28impl fmt::Display for PartitionSource {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::VertexId { tag } => write!(formatter, "vertex tag '{tag}'"),
32            Self::EdgeId { edge_type } => write!(formatter, "edge type '{edge_type}'"),
33            Self::Index { index_name } => write!(formatter, "index '{index_name}'"),
34        }
35    }
36}
37
38/// Distribution strategy of a partition layout.
39///
40/// `Range` splits the source id domain into contiguous slices (the only
41/// strategy that can restrict a storage scan directly). `Hash` aligns rows by
42/// `hash(key) % buckets` across partitions — used when the join/distribution
43/// key cannot be mapped onto the id domain; the scan input is still sliced
44/// into disjoint ranges so every row belongs to exactly one partition, and a
45/// hash exchange redistributes rows to their bucket. `RoundRobin` is reserved
46/// for exchange-level redistribution and is never produced for scans yet.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum PartitionStrategy {
49    Range,
50    Hash { key: String },
51    RoundRobin,
52}
53
54impl fmt::Display for PartitionStrategy {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Range => write!(formatter, "range"),
58            Self::Hash { key } => write!(formatter, "hash(key='{key}')"),
59            Self::RoundRobin => write!(formatter, "round-robin"),
60        }
61    }
62}
63
64/// Physical partition layout selected for a plan.
65///
66/// An absent layout means single-tree execution.  The planner must only set a
67/// layout after it has split the logical plan at a valid exchange boundary.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct PartitionSpec {
70    ranges: Vec<Range<i64>>,
71    /// Data domain these ranges map onto.
72    source: PartitionSource,
73    /// Distribution strategy over the partition set.
74    strategy: PartitionStrategy,
75    /// Number of partitions. Equals `ranges.len()` for `Range`; the bucket
76    /// count for hash/round-robin strategies.
77    partition_count: usize,
78    /// Monotonically-increasing layout version.  When the underlying data
79    /// layout changes this version lets the plan cache detect stale specs.
80    layout_version: Option<u64>,
81}
82
83/// Validation error returned when a physical partition layout cannot be
84/// executed safely.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum PartitionSpecError {
87    Empty,
88    EmptyRange {
89        index: usize,
90    },
91    UnorderedOrOverlapping {
92        index: usize,
93    },
94    /// Bucket count below the minimum viable parallel degree (2).
95    TooFewBuckets,
96    /// A hash key must name the distribution column.
97    EmptyHashKey,
98}
99
100impl fmt::Display for PartitionSpecError {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::Empty => write!(
104                formatter,
105                "partition layout must contain at least one range"
106            ),
107            Self::EmptyRange { index } => {
108                write!(
109                    formatter,
110                    "partition range at index {index} must not be empty"
111                )
112            }
113            Self::UnorderedOrOverlapping { index } => write!(
114                formatter,
115                "partition range at index {index} must be ordered and non-overlapping"
116            ),
117            Self::TooFewBuckets => {
118                write!(formatter, "partition layout requires at least two buckets")
119            }
120            Self::EmptyHashKey => {
121                write!(formatter, "hash partition key must not be empty")
122            }
123        }
124    }
125}
126
127impl Error for PartitionSpecError {}
128
129impl PartitionSpec {
130    /// Create a validated physical partition layout.
131    ///
132    /// Ranges are ordered by start and may be disjoint, but they must not be
133    /// empty or overlap. Keeping this invariant at the plan boundary prevents
134    /// duplicated or missing rows once a scan is copied for each partition.
135    pub fn try_new(
136        ranges: Vec<Range<i64>>,
137        source: PartitionSource,
138        layout_version: Option<u64>,
139    ) -> Result<Self, PartitionSpecError> {
140        if ranges.is_empty() {
141            return Err(PartitionSpecError::Empty);
142        }
143
144        let mut previous_end = None;
145        for (index, range) in ranges.iter().enumerate() {
146            if range.start >= range.end {
147                return Err(PartitionSpecError::EmptyRange { index });
148            }
149            if previous_end.is_some_and(|end| range.start < end) {
150                return Err(PartitionSpecError::UnorderedOrOverlapping { index });
151            }
152            previous_end = Some(range.end);
153        }
154
155        let partition_count = ranges.len();
156        Ok(Self {
157            ranges,
158            source,
159            strategy: PartitionStrategy::Range,
160            partition_count,
161            layout_version,
162        })
163    }
164
165    /// Create a validated hash-partition layout.
166    ///
167    /// Rows are aligned by `hash(key) % buckets` across partitions. The
168    /// `ranges` slices still partition the scan input (every row belongs to
169    /// exactly one slice); a downstream hash exchange performs the actual
170    /// redistribution onto the bucket axis.
171    pub fn try_new_hash(
172        key: impl Into<String>,
173        ranges: Vec<Range<i64>>,
174        source: PartitionSource,
175        layout_version: Option<u64>,
176    ) -> Result<Self, PartitionSpecError> {
177        let key = key.into();
178        if key.is_empty() {
179            return Err(PartitionSpecError::EmptyHashKey);
180        }
181        Self::try_new_bucketed(
182            PartitionStrategy::Hash { key },
183            ranges,
184            source,
185            layout_version,
186        )
187    }
188
189    /// Create a validated round-robin partition layout.
190    ///
191    /// Reserved for exchange-level redistribution; the planner does not yet
192    /// emit round-robin layouts for scans.
193    pub fn try_new_round_robin(
194        ranges: Vec<Range<i64>>,
195        source: PartitionSource,
196        layout_version: Option<u64>,
197    ) -> Result<Self, PartitionSpecError> {
198        Self::try_new_bucketed(
199            PartitionStrategy::RoundRobin,
200            ranges,
201            source,
202            layout_version,
203        )
204    }
205
206    fn try_new_bucketed(
207        strategy: PartitionStrategy,
208        ranges: Vec<Range<i64>>,
209        source: PartitionSource,
210        layout_version: Option<u64>,
211    ) -> Result<Self, PartitionSpecError> {
212        // Reuse the range invariants: the slices must cover the scan input
213        // without overlap so no row is duplicated or lost before the
214        // redistribution exchange.
215        let spec = Self::try_new(ranges, source, layout_version)?;
216        Ok(Self {
217            strategy,
218            partition_count: spec.ranges.len(),
219            ..spec
220        })
221    }
222
223    /// The distribution strategy of this layout.
224    pub fn strategy(&self) -> &PartitionStrategy {
225        &self.strategy
226    }
227
228    pub fn ranges(&self) -> &[Range<i64>] {
229        &self.ranges
230    }
231
232    pub fn partition_count(&self) -> usize {
233        self.partition_count
234    }
235
236    pub fn source(&self) -> &PartitionSource {
237        &self.source
238    }
239
240    pub fn layout_version(&self) -> Option<u64> {
241        self.layout_version
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn test_source() -> PartitionSource {
250        PartitionSource::VertexId {
251            tag: "test".to_string(),
252        }
253    }
254
255    #[test]
256    fn partition_spec_rejects_empty_and_overlapping_ranges() {
257        assert_eq!(
258            PartitionSpec::try_new(Vec::new(), test_source(), None),
259            Err(PartitionSpecError::Empty)
260        );
261        assert_eq!(
262            PartitionSpec::try_new(std::iter::once(0..0).collect(), test_source(), None),
263            Err(PartitionSpecError::EmptyRange { index: 0 })
264        );
265        assert_eq!(
266            PartitionSpec::try_new(vec![0..10, 5..20], test_source(), None),
267            Err(PartitionSpecError::UnorderedOrOverlapping { index: 1 })
268        );
269    }
270
271    #[test]
272    fn partition_spec_stores_source_and_layout_version() {
273        let spec = PartitionSpec::try_new(vec![0..10, 10..20], test_source(), Some(42))
274            .expect("valid spec");
275        assert_eq!(spec.source(), &test_source());
276        assert_eq!(spec.layout_version(), Some(42));
277        assert_eq!(spec.partition_count(), 2);
278        assert_eq!(spec.strategy(), &PartitionStrategy::Range);
279    }
280
281    #[test]
282    fn hash_spec_carries_key_and_bucket_count() {
283        let spec = PartitionSpec::try_new_hash("age", vec![0..50, 50..100], test_source(), Some(7))
284            .expect("valid hash spec");
285        assert_eq!(
286            spec.strategy(),
287            &PartitionStrategy::Hash {
288                key: "age".to_string()
289            }
290        );
291        assert_eq!(spec.partition_count(), 2);
292        assert_eq!(spec.layout_version(), Some(7));
293        // The scan input slices stay disjoint so no row is duplicated.
294        assert_eq!(spec.ranges().len(), 2);
295    }
296
297    #[test]
298    fn hash_spec_rejects_empty_key() {
299        assert_eq!(
300            PartitionSpec::try_new_hash(
301                "",
302                std::iter::once(0..10i64).collect(),
303                test_source(),
304                None
305            )
306            .map(|_| ())
307            .unwrap_err(),
308            PartitionSpecError::EmptyHashKey
309        );
310    }
311
312    #[test]
313    fn round_robin_spec_is_bucketed() {
314        let spec = PartitionSpec::try_new_round_robin(vec![0..10, 10..20], test_source(), None)
315            .expect("valid round-robin spec");
316        assert_eq!(spec.strategy(), &PartitionStrategy::RoundRobin);
317        assert_eq!(spec.partition_count(), 2);
318    }
319}