Skip to main content

linkrs_query/executor/streaming/
partition.rs

1//! Data partitioning abstractions for parallel execution
2//!
3//! Defines PartitionView and related traits for splitting execution
4//! across multiple partitions (e.g., CPU cores).
5
6use std::ops::Range;
7
8use crate::planning::plan::PartitionSpec;
9use linkrs_core::error::QueryError;
10
11/// A view of data partitioned for parallel processing
12///
13/// Partitions allow executor to process data independently,
14/// enabling parallel execution across CPU cores.
15#[derive(Debug, Clone)]
16pub struct PartitionView {
17    /// Total number of partitions
18    pub partition_count: usize,
19    /// IDs of all partitions
20    pub partition_ids: Vec<usize>,
21    /// Range of items (e.g., vertex IDs) for each partition
22    pub partition_ranges: Vec<Range<i64>>,
23}
24
25/// Infallible conversion from a validated `PartitionSpec`.
26/// Skips re-validation because `PartitionSpec::try_new()` already guarantees
27/// non-empty, ordered, non-overlapping ranges.
28impl From<&PartitionSpec> for PartitionView {
29    fn from(spec: &PartitionSpec) -> Self {
30        let partition_count = spec.partition_count();
31        Self {
32            partition_count,
33            partition_ids: (0..partition_count).collect(),
34            partition_ranges: spec.ranges().to_vec(),
35        }
36    }
37}
38
39impl PartitionView {
40    /// Create a validated partition view.
41    pub fn try_new(
42        partition_count: usize,
43        partition_ranges: Vec<Range<i64>>,
44    ) -> Result<Self, QueryError> {
45        if partition_count == 0 || partition_count != partition_ranges.len() {
46            return Err(QueryError::execution(
47                "Partition count must match a non-empty range list".to_string(),
48            ));
49        }
50
51        let mut previous_end = None;
52        for (index, range) in partition_ranges.iter().enumerate() {
53            if range.start >= range.end {
54                return Err(QueryError::execution(format!(
55                    "Partition range at index {index} must not be empty"
56                )));
57            }
58            if previous_end.is_some_and(|end| range.start < end) {
59                return Err(QueryError::execution(format!(
60                    "Partition range at index {index} must be ordered and non-overlapping"
61                )));
62            }
63            previous_end = Some(range.end);
64        }
65
66        let partition_ids = (0..partition_count).collect();
67        Ok(Self {
68            partition_count,
69            partition_ids,
70            partition_ranges,
71        })
72    }
73
74    /// Create a single partition (no partitioning)
75    pub fn single(range: Range<i64>) -> Result<Self, QueryError> {
76        Self::try_new(1, vec![range])
77    }
78
79    /// Split a range into N partitions
80    pub fn from_range(range: Range<i64>, partition_count: usize) -> Result<Self, QueryError> {
81        if partition_count == 0 {
82            return Err(QueryError::execution(
83                "Partition count must be greater than zero".to_string(),
84            ));
85        }
86        if range.start >= range.end {
87            return Err(QueryError::execution(
88                "Partition source range must not be empty".to_string(),
89            ));
90        }
91        let total = range.end - range.start;
92        let per_partition = (total + partition_count as i64 - 1) / partition_count as i64;
93
94        let mut ranges = Vec::new();
95        for i in 0..partition_count {
96            let start = range.start + (i as i64) * per_partition;
97            let end = (start + per_partition).min(range.end);
98            if start < end {
99                ranges.push(start..end);
100            }
101        }
102
103        Self::try_new(ranges.len(), ranges)
104    }
105
106    /// Get the range for a specific partition
107    pub fn get_range(&self, partition_id: usize) -> Option<Range<i64>> {
108        if partition_id < self.partition_ranges.len() {
109            Some(self.partition_ranges[partition_id].clone())
110        } else {
111            None
112        }
113    }
114
115    /// Number of items in a specific partition
116    pub fn partition_size(&self, partition_id: usize) -> i64 {
117        self.get_range(partition_id)
118            .map(|r| r.end - r.start)
119            .unwrap_or(0)
120    }
121
122    /// Total number of items across all partitions
123    pub fn total_size(&self) -> i64 {
124        self.partition_ranges.iter().map(|r| r.end - r.start).sum()
125    }
126}
127
128/// Morsel-parallel reserve decision for the batched flatten path.
129///
130/// Flatten stays on the single-partition row path in this phase: morsel
131/// parallelism over flattened batches is deferred to the columnar parallel
132/// follow-up. Returns a human-readable fallback reason for
133/// `parallel_fallback_reason` observability when the visible row count
134/// exceeds one batch (i.e. parallel execution could have split the work),
135/// otherwise `None` (single batch: no parallelism forgone).
136pub fn flatten_parallel_fallback_reason(visible_rows: usize, batch_size: usize) -> Option<String> {
137    let batch_size = batch_size.max(1);
138    if visible_rows > batch_size {
139        Some(format!(
140            "Flatten: single-partition batch execution (rows={visible_rows} > batch={batch_size}); morsel parallelism deferred"
141        ))
142    } else {
143        None
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_single_partition() {
153        let view = PartitionView::single(0i64..1000).expect("valid single partition");
154        assert_eq!(view.partition_count, 1);
155        assert_eq!(view.total_size(), 1000);
156        assert_eq!(view.get_range(0), Some(0i64..1000));
157    }
158
159    #[test]
160    fn test_split_partitions() {
161        let view = PartitionView::from_range(0i64..1000, 4).expect("valid split");
162        assert_eq!(view.partition_count, 4);
163        assert_eq!(view.total_size(), 1000);
164
165        // Check that partitions cover the entire range
166        let mut prev_end = 0i64;
167        for (i, range) in view.partition_ranges.iter().enumerate() {
168            assert_eq!(range.start, prev_end, "Partition {} is not contiguous", i);
169            prev_end = range.end;
170        }
171        assert_eq!(prev_end, 1000);
172    }
173
174    #[test]
175    fn test_uneven_split() {
176        let view = PartitionView::from_range(0i64..1001, 4).expect("valid uneven split");
177        assert_eq!(view.partition_count, 4);
178        assert_eq!(view.total_size(), 1001);
179    }
180
181    #[test]
182    fn rejects_invalid_partition_layout() {
183        let empty = PartitionView::try_new(0, Vec::new()).expect_err("empty layout must fail");
184        assert!(empty.to_string().contains("non-empty"));
185
186        let overlapping = PartitionView::try_new(2, vec![0i64..10, 5i64..20])
187            .expect_err("overlapping ranges must fail");
188        assert!(overlapping.to_string().contains("non-overlapping"));
189    }
190
191    #[test]
192    fn flatten_parallel_fallback_reason_reports_multi_batch() {
193        assert!(super::flatten_parallel_fallback_reason(10, 2048).is_none());
194        let reason = super::flatten_parallel_fallback_reason(5000, 2048)
195            .expect("multi-batch flatten must report a fallback reason");
196        assert!(reason.contains("single-partition"));
197        assert!(reason.contains("morsel parallelism deferred"));
198    }
199}