Skip to content

Commit ddf77f2

Browse files
authored
perf(cubestore): load-aware placement of CSV import jobs (#11084)
1 parent ee86b13 commit ddf77f2

7 files changed

Lines changed: 563 additions & 48 deletions

File tree

rust/cubestore/cubestore/src/cluster/mod.rs

Lines changed: 174 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,6 @@ pub trait Cluster: DIService + Send + Sync {
182182
chunk: &IdRow<Chunk>,
183183
) -> Result<String, CubeError>;
184184

185-
async fn node_name_for_import(
186-
&self,
187-
table_id: u64,
188-
location: &str,
189-
) -> Result<String, CubeError>;
190-
191185
async fn process_message_on_worker(&self, m: NetworkMessage) -> NetworkMessage;
192186

193187
async fn process_metastore_message(&self, m: NetworkMessage) -> NetworkMessage;
@@ -770,21 +764,6 @@ impl Cluster for ClusterImpl {
770764
}
771765
}
772766

773-
async fn node_name_for_import(
774-
&self,
775-
table_id: u64,
776-
location: &str,
777-
) -> Result<String, CubeError> {
778-
let workers = self.config_obj.select_workers();
779-
if workers.is_empty() {
780-
return Ok(self.server_name.to_string());
781-
}
782-
let mut hasher = DefaultHasher::new();
783-
table_id.hash(&mut hasher);
784-
location.hash(&mut hasher);
785-
Ok(workers[(hasher.finish() % workers.len() as u64) as usize].to_string())
786-
}
787-
788767
async fn warmup_partition(
789768
&self,
790769
partition: IdRow<Partition>,
@@ -2223,6 +2202,37 @@ pub fn pick_worker_by_ids<'a>(
22232202
workers[(hasher.finish() % workers.len() as u64) as usize].as_str()
22242203
}
22252204

2205+
/// Load-aware placement for a single CSV import: pick the worker with the fewest in-flight
2206+
/// imports (per `counts`), breaking ties with the stable hash of (table_id, location). Workers
2207+
/// absent from `counts` are treated as zero. The caller owns `counts` and increments the chosen
2208+
/// worker between calls so a batch placed in one loop spreads evenly without re-scanning.
2209+
pub fn pick_import_worker(
2210+
config: &dyn ConfigObj,
2211+
table_id: u64,
2212+
location: &str,
2213+
counts: &HashMap<String, u64>,
2214+
) -> String {
2215+
let workers = config.select_workers();
2216+
if workers.is_empty() {
2217+
return config.server_name().to_string();
2218+
}
2219+
2220+
let min_count = workers
2221+
.iter()
2222+
.map(|w| counts.get(w).copied().unwrap_or(0))
2223+
.min()
2224+
.unwrap();
2225+
let min_set: Vec<&String> = workers
2226+
.iter()
2227+
.filter(|w| counts.get(*w).copied().unwrap_or(0) == min_count)
2228+
.collect();
2229+
2230+
let mut hasher = DefaultHasher::new();
2231+
table_id.hash(&mut hasher);
2232+
location.hash(&mut hasher);
2233+
min_set[(hasher.finish() % min_set.len() as u64) as usize].to_string()
2234+
}
2235+
22262236
/// Same as [pick_worker_by_ids], but uses ranges of partitions. This is a hack
22272237
/// to keep the same node for partitions produced by compaction that merged
22282238
/// chunks into the main table of a single partition.
@@ -2243,3 +2253,146 @@ pub fn pick_worker_by_partitions<'a>(
22432253
}
22442254
workers[(hasher.finish() % workers.len() as u64) as usize].as_str()
22452255
}
2256+
2257+
#[cfg(test)]
2258+
mod tests {
2259+
use super::*;
2260+
use crate::config::Config;
2261+
use std::fs;
2262+
2263+
fn config_with_workers(name: &str, workers: Vec<String>) -> Arc<dyn ConfigObj> {
2264+
Config::test(name)
2265+
.update_config(|mut c| {
2266+
c.select_workers = workers;
2267+
c
2268+
})
2269+
.config_obj()
2270+
}
2271+
2272+
#[test]
2273+
fn pick_import_worker_load_aware() {
2274+
let config = config_with_workers(
2275+
"pick_import_worker_load_aware",
2276+
vec![
2277+
"worker1".to_string(),
2278+
"worker2".to_string(),
2279+
"worker3".to_string(),
2280+
],
2281+
);
2282+
// worker1 loaded, worker2 lightly loaded, worker3 absent (counts as 0).
2283+
let counts = HashMap::from([("worker1".to_string(), 3), ("worker2".to_string(), 1)]);
2284+
assert_eq!(
2285+
pick_import_worker(config.as_ref(), 100, "new-loc", &counts),
2286+
"worker3"
2287+
);
2288+
}
2289+
2290+
#[test]
2291+
fn pick_import_worker_converges_to_even_spread() {
2292+
let config = config_with_workers(
2293+
"pick_import_worker_converges_to_even_spread",
2294+
(0..4).map(|i| format!("worker{}", i)).collect(),
2295+
);
2296+
// Emulate one placement loop: pick + locally increment, as the scheduler does.
2297+
let mut counts: HashMap<String, u64> = HashMap::new();
2298+
for i in 0..40u64 {
2299+
let node = pick_import_worker(config.as_ref(), i, &format!("loc-{}", i), &counts);
2300+
*counts.entry(node).or_insert(0) += 1;
2301+
}
2302+
assert_eq!(counts.values().sum::<u64>(), 40);
2303+
for i in 0..4 {
2304+
assert_eq!(counts.get(&format!("worker{}", i)).copied(), Some(10));
2305+
}
2306+
}
2307+
2308+
#[test]
2309+
fn pick_import_worker_tie_break_is_hash_stable() {
2310+
let config = config_with_workers(
2311+
"pick_import_worker_tie_break_is_hash_stable",
2312+
vec![
2313+
"worker1".to_string(),
2314+
"worker2".to_string(),
2315+
"worker3".to_string(),
2316+
],
2317+
);
2318+
// All workers tie at 0: pick is the stable hash over the full set, deterministic.
2319+
let counts = HashMap::new();
2320+
let first = pick_import_worker(config.as_ref(), 7, "loc-7", &counts);
2321+
let second = pick_import_worker(config.as_ref(), 7, "loc-7", &counts);
2322+
assert_eq!(first, second);
2323+
assert!(config.select_workers().contains(&first));
2324+
}
2325+
2326+
#[test]
2327+
fn pick_import_worker_empty_workers_fallback() {
2328+
let config = config_with_workers("pick_import_worker_empty_workers_fallback", vec![]);
2329+
assert!(config.select_workers().is_empty());
2330+
let counts = HashMap::new();
2331+
assert_eq!(
2332+
&pick_import_worker(config.as_ref(), 1, "loc", &counts),
2333+
config.server_name()
2334+
);
2335+
}
2336+
2337+
#[tokio::test]
2338+
async fn in_flight_import_jobs_by_node_counts_scheduled_and_processing() {
2339+
async fn add_import_job(
2340+
meta_store: &Arc<dyn MetaStore>,
2341+
table_id: u64,
2342+
location: &str,
2343+
node: &str,
2344+
processing: bool,
2345+
) {
2346+
let job = meta_store
2347+
.add_job(Job::new(
2348+
RowKey::Table(TableId::Tables, table_id),
2349+
JobType::TableImportCSV(location.to_string()),
2350+
node.to_string(),
2351+
))
2352+
.await
2353+
.unwrap()
2354+
.unwrap();
2355+
if processing {
2356+
meta_store
2357+
.update_status(job.get_id(), JobStatus::ProcessingBy(node.to_string()))
2358+
.await
2359+
.unwrap();
2360+
}
2361+
}
2362+
2363+
// The jobs' nodes must be in select_workers, otherwise the scheduler's reconcile loop
2364+
// (remove_jobs_on_non_exists_nodes) would drop them out from under the scan.
2365+
let config = Config::test("in_flight_import_jobs_by_node_counts_scheduled_and_processing")
2366+
.update_config(|mut c| {
2367+
c.select_workers = vec![
2368+
"worker1".to_string(),
2369+
"worker2".to_string(),
2370+
"worker3".to_string(),
2371+
];
2372+
c
2373+
});
2374+
let _ = fs::remove_dir_all(config.local_dir());
2375+
let _ = fs::remove_dir_all(config.remote_dir());
2376+
2377+
let services = config.configure().await;
2378+
services.start_processing_loops().await.unwrap();
2379+
let meta_store = services.meta_store.clone();
2380+
2381+
// worker1 has 3 (mix of scheduled/processing), worker2 has 1, worker3 has none.
2382+
add_import_job(&meta_store, 1, "loc-1", "worker1", false).await;
2383+
add_import_job(&meta_store, 2, "loc-2", "worker1", true).await;
2384+
add_import_job(&meta_store, 3, "loc-3", "worker1", false).await;
2385+
add_import_job(&meta_store, 4, "loc-4", "worker2", true).await;
2386+
// Stream imports must not be counted.
2387+
add_import_job(&meta_store, 5, "stream:topic", "worker3", false).await;
2388+
2389+
let counts = meta_store.in_flight_import_jobs_by_node().await.unwrap();
2390+
assert_eq!(counts.get("worker1").copied(), Some(3));
2391+
assert_eq!(counts.get("worker2").copied(), Some(1));
2392+
assert_eq!(counts.get("worker3").copied(), None);
2393+
2394+
services.stop_processing_loops().await.unwrap();
2395+
let _ = fs::remove_dir_all(config.local_dir());
2396+
let _ = fs::remove_dir_all(config.remote_dir());
2397+
}
2398+
}

rust/cubestore/cubestore/src/config/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,11 @@ pub trait ConfigObj: DIService {
501501

502502
fn enable_topk(&self) -> bool;
503503

504+
/// When enabled, a TableImportCSV job is placed on the worker with the fewest in-flight
505+
/// CSV imports (load-aware), instead of by stateless hash of (table_id, location). Off by
506+
/// default (hash placement); kept as a flag for rollout/rollback.
507+
fn load_aware_import_placement_enabled(&self) -> bool;
508+
504509
/// When enabled, an inactive parent partition is repartitioned by a single
505510
/// per-partition job that loops over its persisted chunks, instead of one
506511
/// job per chunk. Kept as a flag for emergency rollback.
@@ -657,6 +662,7 @@ pub struct ConfigObjImpl {
657662
pub max_ingestion_data_frames: usize,
658663
pub upload_to_remote: bool,
659664
pub enable_topk: bool,
665+
pub load_aware_import_placement_enabled: bool,
660666
pub batch_repartition_enabled: bool,
661667
pub repartition_chunks_time_budget_secs: u64,
662668
pub allow_decimal128: bool,
@@ -968,6 +974,9 @@ impl ConfigObj for ConfigObjImpl {
968974
fn enable_topk(&self) -> bool {
969975
self.enable_topk
970976
}
977+
fn load_aware_import_placement_enabled(&self) -> bool {
978+
self.load_aware_import_placement_enabled
979+
}
971980
fn batch_repartition_enabled(&self) -> bool {
972981
self.batch_repartition_enabled
973982
}
@@ -1564,6 +1573,10 @@ impl Config {
15641573
.unwrap_or("localhost".to_string()),
15651574
upload_to_remote: !env::var("CUBESTORE_NO_UPLOAD").ok().is_some(),
15661575
enable_topk: env_bool("CUBESTORE_ENABLE_TOPK", true),
1576+
load_aware_import_placement_enabled: env_bool(
1577+
"CUBESTORE_LOAD_AWARE_IMPORT_PLACEMENT",
1578+
false,
1579+
),
15671580
batch_repartition_enabled: env_bool("CUBESTORE_BATCH_REPARTITION", true),
15681581
repartition_chunks_time_budget_secs: env_parse(
15691582
"CUBESTORE_REPARTITION_TIME_BUDGET_SECS",
@@ -1806,6 +1819,7 @@ impl Config {
18061819
server_name: "localhost".to_string(),
18071820
upload_to_remote: true,
18081821
enable_topk: true,
1822+
load_aware_import_placement_enabled: false,
18091823
batch_repartition_enabled: true,
18101824
repartition_chunks_time_budget_secs: 60,
18111825
allow_decimal128: false,

rust/cubestore/cubestore/src/metastore/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,7 @@ pub trait MetaStore: DIService + Send + Sync {
11111111
async fn get_wals_for_table(&self, table_id: u64) -> Result<Vec<IdRow<WAL>>, CubeError>;
11121112

11131113
async fn all_jobs(&self) -> Result<Vec<IdRow<Job>>, CubeError>;
1114+
async fn in_flight_import_jobs_by_node(&self) -> Result<HashMap<String, u64>, CubeError>;
11141115
async fn add_job(&self, job: Job) -> Result<Option<IdRow<Job>>, CubeError>;
11151116
async fn get_job(&self, job_id: u64) -> Result<IdRow<Job>, CubeError>;
11161117
async fn get_job_by_ref(
@@ -4205,6 +4206,28 @@ impl MetaStore for RocksMetaStore {
42054206
.await
42064207
}
42074208

4209+
#[tracing::instrument(level = "trace", skip(self))]
4210+
async fn in_flight_import_jobs_by_node(&self) -> Result<HashMap<String, u64>, CubeError> {
4211+
self.read_operation_out_of_queue("in_flight_import_jobs_by_node", move |db_ref| {
4212+
let jobs_table = JobRocksTable::new(db_ref);
4213+
let mut counts: HashMap<String, u64> = HashMap::new();
4214+
for job in jobs_table.scan_all_rows()? {
4215+
let job = job?;
4216+
if !job.get_row().is_csv_import() {
4217+
continue;
4218+
}
4219+
match job.get_row().status() {
4220+
JobStatus::Scheduled(node) | JobStatus::ProcessingBy(node) => {
4221+
*counts.entry(node.to_string()).or_insert(0) += 1;
4222+
}
4223+
_ => {}
4224+
}
4225+
}
4226+
Ok(counts)
4227+
})
4228+
.await
4229+
}
4230+
42084231
#[tracing::instrument(level = "trace", skip(self))]
42094232
async fn add_job(&self, job: Job) -> Result<Option<IdRow<Job>>, CubeError> {
42104233
self.write_operation("add_job", move |db_ref, batch_pipe| {

rust/cubestore/cubestore/src/queryplanner/test_utils.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,10 @@ impl MetaStore for MetaStoreMock {
596596
panic!("MetaStore mock!")
597597
}
598598

599+
async fn in_flight_import_jobs_by_node(&self) -> Result<HashMap<String, u64>, CubeError> {
600+
panic!("MetaStore mock!")
601+
}
602+
599603
async fn add_job(&self, _job: Job) -> Result<Option<IdRow<Job>>, CubeError> {
600604
panic!("MetaStore mock!")
601605
}

0 commit comments

Comments
 (0)