Skip to content

Commit 66a736d

Browse files
Route DataFusion scan tasks by file affinity
1 parent e5cacdd commit 66a736d

5 files changed

Lines changed: 225 additions & 7 deletions

File tree

quickwit/Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quickwit/quickwit-datafusion/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ datafusion-substrait = "53"
3939
datafusion-datasource = "53"
4040
datafusion-physical-plan = "53"
4141
datafusion-datasource-parquet = "53"
42-
datafusion-distributed = "1.0"
42+
datafusion-distributed = { git = "https://github.com/datafusion-contrib/datafusion-distributed.git", rev = "d7de9c3fb99fd7fa99da925b460dc2d4529c04a2" }
4343
object_store = "0.13"
4444

4545
[dev-dependencies]

quickwit/quickwit-df-core/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ async-trait = { workspace = true }
1515
futures = { workspace = true }
1616
prost = { workspace = true } # substrait Plan::decode (runtime only)
1717
serde_json = { workspace = true }
18+
siphasher = { workspace = true }
1819
tracing = { workspace = true }
1920
url = "2"
2021

2122
arrow = { workspace = true }
2223
datafusion = "53"
2324
datafusion-substrait = "53"
24-
datafusion-distributed = "1.0"
25+
datafusion-distributed = { git = "https://github.com/datafusion-contrib/datafusion-distributed.git", rev = "d7de9c3fb99fd7fa99da925b460dc2d4529c04a2" }
2526
object_store = "0.13"
2627

2728
# gRPC surface is opt-in. The extension traits + session builder do not need

quickwit/quickwit-df-core/src/session.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ use datafusion::execution::object_store::ObjectStoreRegistry;
4545
use datafusion::execution::runtime_env::{RuntimeEnv, RuntimeEnvBuilder};
4646
use datafusion::prelude::{SessionConfig, SessionContext};
4747
use datafusion_distributed::{
48-
DistributedExt, DistributedPhysicalOptimizerRule, TaskEstimator, WorkerResolver,
48+
DistributedExt, SessionStateBuilderExt, TaskEstimator, TaskRoutingContext, WorkerResolver,
4949
};
5050

5151
use crate::data_source::{
@@ -346,7 +346,7 @@ impl DataFusionSessionBuilder {
346346
builder = builder
347347
.with_distributed_worker_resolver(ArcWorkerResolver(Arc::clone(resolver)))
348348
.with_distributed_task_estimator(ArcTaskEstimator(Arc::clone(&self.task_estimator)))
349-
.with_physical_optimizer_rule(Arc::new(DistributedPhysicalOptimizerRule));
349+
.with_distributed_planner();
350350
}
351351

352352
Ok(SessionContext::new_with_state(builder.build()))
@@ -386,6 +386,10 @@ impl TaskEstimator for ArcTaskEstimator {
386386
) -> Option<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
387387
self.0.scale_up_leaf_node(plan, task_count, cfg)
388388
}
389+
390+
fn route_tasks(&self, routing_ctx: &TaskRoutingContext<'_>) -> DFResult<Option<Vec<url::Url>>> {
391+
self.0.route_tasks(routing_ctx)
392+
}
389393
}
390394

391395
#[cfg(test)]

quickwit/quickwit-df-core/src/task_estimator.rs

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@
1919
//! count equals the file-group count; for tantivy sources it equals the total
2020
//! segment count across splits.
2121
22+
use std::hash::{Hash, Hasher};
2223
use std::sync::Arc;
2324

25+
use datafusion::common::Result as DFResult;
2426
use datafusion::config::ConfigOptions;
27+
use datafusion::datasource::physical_plan::FileScanConfig;
2528
use datafusion::datasource::source::DataSourceExec;
2629
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;
2835

2936
/// Estimates distributed task count from the partition count of a
3037
/// `DataSourceExec` leaf. Returns `None` for any other plan node, letting the
@@ -61,4 +68,210 @@ impl TaskEstimator for DataSourceExecPartitionEstimator {
6168
task_count,
6269
)))
6370
}
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+
}
64277
}

0 commit comments

Comments
 (0)