@@ -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+ }
0 commit comments