|
19 | 19 | //! count equals the file-group count; for tantivy sources it equals the total |
20 | 20 | //! segment count across splits. |
21 | 21 |
|
| 22 | +use std::hash::{Hash, Hasher}; |
22 | 23 | use std::sync::Arc; |
23 | 24 |
|
| 25 | +use datafusion::common::Result as DFResult; |
24 | 26 | use datafusion::config::ConfigOptions; |
| 27 | +use datafusion::datasource::physical_plan::FileScanConfig; |
25 | 28 | use datafusion::datasource::source::DataSourceExec; |
26 | 29 | use datafusion::physical_plan::ExecutionPlan; |
27 | | -use datafusion_distributed::{PartitionIsolatorExec, TaskEstimation, TaskEstimator}; |
| 30 | +use datafusion_distributed::{ |
| 31 | + PartitionIsolatorExec, TaskEstimation, TaskEstimator, TaskRoutingContext, |
| 32 | +}; |
| 33 | +use siphasher::sip::SipHasher; |
| 34 | +use url::Url; |
28 | 35 |
|
29 | 36 | /// Estimates distributed task count from the partition count of a |
30 | 37 | /// `DataSourceExec` leaf. Returns `None` for any other plan node, letting the |
@@ -61,4 +68,210 @@ impl TaskEstimator for DataSourceExecPartitionEstimator { |
61 | 68 | task_count, |
62 | 69 | ))) |
63 | 70 | } |
| 71 | + |
| 72 | + fn route_tasks(&self, routing_ctx: &TaskRoutingContext<'_>) -> DFResult<Option<Vec<Url>>> { |
| 73 | + if routing_ctx.available_urls.is_empty() { |
| 74 | + return Ok(None); |
| 75 | + } |
| 76 | + let Some(file_scan) = single_file_scan_config(routing_ctx.plan) else { |
| 77 | + return Ok(None); |
| 78 | + }; |
| 79 | + let task_affinities = file_scan_task_affinities(file_scan, routing_ctx.task_count); |
| 80 | + if task_affinities.len() != routing_ctx.task_count { |
| 81 | + return Ok(None); |
| 82 | + } |
| 83 | + Ok(Some(route_by_rendezvous( |
| 84 | + &task_affinities, |
| 85 | + routing_ctx.available_urls, |
| 86 | + ))) |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +#[derive(Debug, Clone, Eq, PartialEq)] |
| 91 | +struct TaskAffinity { |
| 92 | + task_index: usize, |
| 93 | + affinity_key: String, |
| 94 | +} |
| 95 | + |
| 96 | +fn single_file_scan_config(plan: &Arc<dyn ExecutionPlan>) -> Option<&FileScanConfig> { |
| 97 | + let mut file_scans = Vec::new(); |
| 98 | + collect_file_scan_configs(plan, &mut file_scans); |
| 99 | + match file_scans.as_slice() { |
| 100 | + [file_scan] => Some(*file_scan), |
| 101 | + _ => None, |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +fn collect_file_scan_configs<'a>( |
| 106 | + plan: &'a Arc<dyn ExecutionPlan>, |
| 107 | + file_scans: &mut Vec<&'a FileScanConfig>, |
| 108 | +) { |
| 109 | + if let Some(data_source_exec) = plan.as_any().downcast_ref::<DataSourceExec>() |
| 110 | + && let Some(file_scan) = data_source_exec |
| 111 | + .data_source() |
| 112 | + .as_any() |
| 113 | + .downcast_ref::<FileScanConfig>() |
| 114 | + { |
| 115 | + file_scans.push(file_scan); |
| 116 | + } |
| 117 | + |
| 118 | + for child in plan.children() { |
| 119 | + collect_file_scan_configs(child, file_scans); |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +fn file_scan_task_affinities(file_scan: &FileScanConfig, task_count: usize) -> Vec<TaskAffinity> { |
| 124 | + partition_groups(file_scan.file_groups.len(), task_count) |
| 125 | + .into_iter() |
| 126 | + .enumerate() |
| 127 | + .map(|(task_index, partition_group)| { |
| 128 | + let mut file_keys = Vec::new(); |
| 129 | + for partition_idx in partition_group { |
| 130 | + if let Some(file_group) = file_scan.file_groups.get(partition_idx) { |
| 131 | + for file in file_group.files() { |
| 132 | + file_keys.push(format!( |
| 133 | + "{}{}", |
| 134 | + file_scan.object_store_url.as_str(), |
| 135 | + file.object_meta.location.as_ref() |
| 136 | + )); |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + file_keys.sort_unstable(); |
| 141 | + let affinity_key = if file_keys.is_empty() { |
| 142 | + format!("empty-task-{task_index}") |
| 143 | + } else { |
| 144 | + file_keys.join("\n") |
| 145 | + }; |
| 146 | + TaskAffinity { |
| 147 | + task_index, |
| 148 | + affinity_key, |
| 149 | + } |
| 150 | + }) |
| 151 | + .collect() |
| 152 | +} |
| 153 | + |
| 154 | +fn partition_groups(input_partitions: usize, task_count: usize) -> Vec<Vec<usize>> { |
| 155 | + if task_count == 0 { |
| 156 | + return Vec::new(); |
| 157 | + } |
| 158 | + let q = input_partitions / task_count; |
| 159 | + let r = input_partitions % task_count; |
| 160 | + let mut off = 0; |
| 161 | + (0..task_count) |
| 162 | + .map(|i| q + usize::from(i < r)) |
| 163 | + .map(|n| { |
| 164 | + let result = (off..(off + n)).collect(); |
| 165 | + off += n; |
| 166 | + result |
| 167 | + }) |
| 168 | + .collect() |
| 169 | +} |
| 170 | + |
| 171 | +fn route_by_rendezvous(task_affinities: &[TaskAffinity], available_urls: &[Url]) -> Vec<Url> { |
| 172 | + let mut routes: Vec<Option<Url>> = vec![None; task_affinities.len()]; |
| 173 | + let mut task_order: Vec<usize> = (0..task_affinities.len()).collect(); |
| 174 | + task_order.sort_unstable_by(|&left, &right| { |
| 175 | + task_affinities[left] |
| 176 | + .affinity_key |
| 177 | + .cmp(&task_affinities[right].affinity_key) |
| 178 | + .then_with(|| { |
| 179 | + task_affinities[left] |
| 180 | + .task_index |
| 181 | + .cmp(&task_affinities[right].task_index) |
| 182 | + }) |
| 183 | + }); |
| 184 | + |
| 185 | + for task_idx in task_order { |
| 186 | + let task = &task_affinities[task_idx]; |
| 187 | + let mut candidates: Vec<usize> = (0..available_urls.len()).collect(); |
| 188 | + candidates.sort_unstable_by(|&left, &right| { |
| 189 | + let left_score = rendezvous_affinity(&available_urls[left], &task.affinity_key); |
| 190 | + let right_score = rendezvous_affinity(&available_urls[right], &task.affinity_key); |
| 191 | + right_score.cmp(&left_score).then_with(|| { |
| 192 | + available_urls[left] |
| 193 | + .as_str() |
| 194 | + .cmp(available_urls[right].as_str()) |
| 195 | + }) |
| 196 | + }); |
| 197 | + let chosen_idx = candidates[0]; |
| 198 | + routes[task.task_index] = Some(available_urls[chosen_idx].clone()); |
| 199 | + } |
| 200 | + |
| 201 | + routes |
| 202 | + .into_iter() |
| 203 | + .map(|route| route.expect("every task should be routed")) |
| 204 | + .collect() |
| 205 | +} |
| 206 | + |
| 207 | +fn rendezvous_affinity(url: &Url, key: &str) -> u64 { |
| 208 | + let mut state = SipHasher::new(); |
| 209 | + key.hash(&mut state); |
| 210 | + url.as_str().hash(&mut state); |
| 211 | + state.finish() |
| 212 | +} |
| 213 | + |
| 214 | +#[cfg(test)] |
| 215 | +mod tests { |
| 216 | + use super::*; |
| 217 | + |
| 218 | + fn url(value: &str) -> Url { |
| 219 | + Url::parse(value).unwrap() |
| 220 | + } |
| 221 | + |
| 222 | + fn task(task_index: usize, affinity_key: &str) -> TaskAffinity { |
| 223 | + TaskAffinity { |
| 224 | + task_index, |
| 225 | + affinity_key: affinity_key.to_string(), |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + #[test] |
| 230 | + fn partition_groups_match_partition_isolator_layout() { |
| 231 | + assert_eq!(partition_groups(2, 1), vec![vec![0, 1]]); |
| 232 | + assert_eq!(partition_groups(6, 2), vec![vec![0, 1, 2], vec![3, 4, 5]]); |
| 233 | + assert_eq!( |
| 234 | + partition_groups(10, 3), |
| 235 | + vec![vec![0, 1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]] |
| 236 | + ); |
| 237 | + } |
| 238 | + |
| 239 | + #[test] |
| 240 | + fn rendezvous_routing_is_stable_across_url_order() { |
| 241 | + let tasks = vec![ |
| 242 | + task(0, "s3://bucket/metrics-0.parquet"), |
| 243 | + task(1, "s3://bucket/metrics-1.parquet"), |
| 244 | + task(2, "s3://bucket/metrics-2.parquet"), |
| 245 | + task(3, "s3://bucket/metrics-3.parquet"), |
| 246 | + task(4, "s3://bucket/metrics-4.parquet"), |
| 247 | + task(5, "s3://bucket/metrics-5.parquet"), |
| 248 | + ]; |
| 249 | + let urls = vec![ |
| 250 | + url("http://node-0:7281"), |
| 251 | + url("http://node-1:7281"), |
| 252 | + url("http://node-2:7281"), |
| 253 | + ]; |
| 254 | + let mut reversed_urls = urls.clone(); |
| 255 | + reversed_urls.reverse(); |
| 256 | + |
| 257 | + let routes = route_by_rendezvous(&tasks, &urls); |
| 258 | + let reversed_routes = route_by_rendezvous(&tasks, &reversed_urls); |
| 259 | + |
| 260 | + assert_eq!(routes, reversed_routes); |
| 261 | + } |
| 262 | + |
| 263 | + #[test] |
| 264 | + fn rendezvous_routing_uses_best_affinity_node_per_task() { |
| 265 | + let tasks = vec![task(0, "s3://bucket/metrics.parquet")]; |
| 266 | + let urls = vec![url("http://node-0:7281"), url("http://node-1:7281")]; |
| 267 | + |
| 268 | + let routes = route_by_rendezvous(&tasks, &urls); |
| 269 | + let expected = urls |
| 270 | + .iter() |
| 271 | + .max_by_key(|url| rendezvous_affinity(url, "s3://bucket/metrics.parquet")) |
| 272 | + .unwrap() |
| 273 | + .clone(); |
| 274 | + |
| 275 | + assert_eq!(routes, vec![expected]); |
| 276 | + } |
64 | 277 | } |
0 commit comments