@@ -18,13 +18,18 @@ const TARGET_RANGES_PER_WORKER: f64 = 8.0;
1818
1919/// Computes up to `num_workers - 1` partition boundaries that divide the primary key space
2020/// into `num_workers` roughly even partitions. This should be run in a transaction.
21+ ///
22+ /// At most `max_requests` probes are issued against the server. When the
23+ /// budget runs out, remaining ranges stay unsplit, which skews partition
24+ /// sizes but never correctness.
2125pub async fn partition_table (
2226 conn : & mut mysql_async:: Conn ,
2327 table : QualifiedTableRef < ' _ > ,
2428 pk_col : & str ,
2529 num_workers : usize ,
2630 estimated_row_count : u64 ,
2731 min_rows_per_worker : u64 ,
32+ max_requests : u64 ,
2833) -> Result < Vec < String > , MySqlError > {
2934 let ( schema_name, table_name) = ( table. schema_name , table. table_name ) ;
3035 let mut db = KeyProber :: new ( conn, table, pk_col) ;
@@ -33,6 +38,7 @@ pub async fn partition_table(
3338 num_workers,
3439 estimated_row_count,
3540 min_rows_per_worker,
41+ max_requests,
3642 )
3743 . await ?;
3844 tracing:: trace!(
@@ -61,6 +67,7 @@ async fn partition<D: PrimaryKeyProber>(
6167 workers : usize ,
6268 estimated_row_count : u64 ,
6369 min_rows_per_worker : u64 ,
70+ max_requests : u64 ,
6471) -> Result < Vec < String > , MySqlError > {
6572 if workers <= 1 {
6673 return Ok ( Vec :: new ( ) ) ;
@@ -70,7 +77,13 @@ async fn partition<D: PrimaryKeyProber>(
7077 let target_max_rows_per_range =
7178 get_target_max_rows_per_range ( workers, estimated_row_count, min_rows_per_worker) ;
7279 // Should be many more ranges than workers unless the overall row count of the table is quite small.
73- let ranges = split_into_ranges ( db, estimated_row_count, target_max_rows_per_range) . await ?;
80+ let ranges = split_into_ranges (
81+ db,
82+ estimated_row_count,
83+ target_max_rows_per_range,
84+ max_requests,
85+ )
86+ . await ?;
7487 Ok ( assign_boundaries ( & ranges, workers) )
7588}
7689
@@ -101,7 +114,9 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
101114 db : & mut D ,
102115 estimated_row_count : f64 ,
103116 target_rows_per_bucket : f64 ,
117+ max_requests : u64 ,
104118) -> Result < Vec < Range > , MySqlError > {
119+ let mut budget = max_requests;
105120 let mut ranges = vec ! [ Range {
106121 prefix: None ,
107122 end: None ,
@@ -112,9 +127,12 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
112127 let mut split_any = false ;
113128 let mut next: Vec < Range > = Vec :: with_capacity ( ranges. len ( ) ) ;
114129 for range in ranges {
115- if range. estimated_rows > target_rows_per_bucket {
130+ // Entering a split costs one probe for the first prefix plus
131+ // up to four for its first walk step, so a budget below that keeps
132+ // the range as a leaf.
133+ if range. estimated_rows > target_rows_per_bucket && budget >= 4 {
116134 split_any = true ;
117- next. extend ( split_range ( db, & range, target_rows_per_bucket) . await ?) ;
135+ next. extend ( split_range ( db, & range, target_rows_per_bucket, & mut budget ) . await ?) ;
118136 } else {
119137 next. push ( range) ;
120138 }
@@ -124,6 +142,11 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
124142 break ;
125143 }
126144 }
145+ tracing:: debug!(
146+ ranges = ranges. len( ) ,
147+ requests_spent = max_requests - budget,
148+ "split key space into ranges"
149+ ) ;
127150 Ok ( ranges)
128151}
129152
@@ -160,14 +183,20 @@ fn assign_boundaries(ranges: &[Range], workers: usize) -> Vec<String> {
160183/// Splits `parent` at every distinct key prefix one character longer than the
161184/// prefix `parent`, i.e. "a" depth: 1 for a table with keys "a", "aa", "aaa", "ab" would be split to
162185/// "a" depth: 2 estimate 1, "aa" depth: 2 estimate 2, "ab" depth 2, estimate 1.
186+ ///
187+ /// `budget` is decremented once per probe. The caller must provide at least
188+ /// 4, one for the first prefix and three for a walk step, and the walk closes
189+ /// out with a tail child once it cannot afford another step.
163190async fn split_range < D : PrimaryKeyProber > (
164191 db : & mut D ,
165192 parent : & Range ,
166193 target_rows : f64 ,
194+ budget : & mut u64 ,
167195) -> Result < Vec < Range > , MySqlError > {
168196 let depth = parent. depth + 1 ;
169197 let mut children = Vec :: new ( ) ;
170198
199+ * budget -= 1 ;
171200 let Some ( mut cur) = db
172201 . prefix_of_first_key_in_range ( parent. prefix . as_deref ( ) , parent. end . as_deref ( ) , depth)
173202 . await ?
@@ -179,12 +208,15 @@ async fn split_range<D: PrimaryKeyProber>(
179208
180209 loop {
181210 // Small optimization to return early if the remaining rows past the current start prefix
182- // fits within the threshold we're looking for.
211+ // fits within the threshold we're looking for. An exhausted budget likewise closes out
212+ // the split, continuing costs up to three more probes plus the next iteration's estimate before
213+ // another chance to stop.
214+ * budget -= 1 ;
183215 let remaining = db
184216 . estimate_range_rows ( start. as_deref ( ) , parent. end . as_deref ( ) )
185217 . await ?;
186218 let remaining = f64:: cast_lossy ( remaining) . max ( 1.0 ) ;
187- if remaining <= target_rows {
219+ if remaining <= target_rows || * budget < 4 {
188220 children. push ( Range {
189221 prefix : start,
190222 end : parent. end . clone ( ) ,
@@ -198,12 +230,14 @@ async fn split_range<D: PrimaryKeyProber>(
198230 // extensions still need visiting. The first key past `cur` exposes
199231 // them, so the walk can keep splitting instead of retrying the whole
200232 // range at greater depths forever.
233+ * budget -= 1 ;
201234 let next = match db
202235 . prefix_of_first_row_not_matching_prefix ( & cur, parent. end . as_deref ( ) , depth)
203236 . await ?
204237 {
205238 Some ( next) => Some ( next) ,
206239 None => {
240+ * budget -= 1 ;
207241 db. prefix_of_first_key_in_range ( Some ( & cur) , parent. end . as_deref ( ) , depth)
208242 . await ?
209243 }
@@ -212,6 +246,7 @@ async fn split_range<D: PrimaryKeyProber>(
212246 // server produces one), stop splitting here.
213247 match next. filter ( |next| next != & cur) {
214248 Some ( next) => {
249+ * budget -= 1 ;
215250 let estimated_rows = db
216251 . estimate_range_rows ( start. as_deref ( ) , Some ( & next) )
217252 . await ?;
@@ -304,9 +339,15 @@ mod tests {
304339 /// "estimates". Byte order stands in for the collation.
305340 struct MockDb {
306341 keys : Vec < String > ,
342+ /// Probes served, for asserting on the request budget.
343+ requests : u64 ,
307344 }
308345
309346 impl MockDb {
347+ fn new ( keys : Vec < String > ) -> Self {
348+ MockDb { keys, requests : 0 }
349+ }
350+
310351 fn bounds ( & self , start : Option < & str > , end : Option < & str > ) -> ( usize , usize ) {
311352 // The lower bound is exclusive, a key equal to `start` is skipped.
312353 let lo = match start {
@@ -327,6 +368,7 @@ mod tests {
327368 start : Option < & str > ,
328369 end : Option < & str > ,
329370 ) -> Result < u64 , MySqlError > {
371+ self . requests += 1 ;
330372 let ( lo, hi) = self . bounds ( start, end) ;
331373 Ok ( u64:: cast_from ( hi - lo) )
332374 }
@@ -337,6 +379,7 @@ mod tests {
337379 end : Option < & str > ,
338380 len : usize ,
339381 ) -> Result < Option < String > , MySqlError > {
382+ self . requests += 1 ;
340383 let ( lo, hi) = self . bounds ( start, end) ;
341384 if lo >= hi {
342385 return Ok ( None ) ;
@@ -350,6 +393,7 @@ mod tests {
350393 end : Option < & str > ,
351394 len : usize ,
352395 ) -> Result < Option < String > , MySqlError > {
396+ self . requests += 1 ;
353397 let ( _, hi) = self . bounds ( None , end) ;
354398 // Find the last key matching `cur`, byte prefixes stand in for
355399 // the collation's LIKE matching.
@@ -370,38 +414,36 @@ mod tests {
370414
371415 #[ mz_ore:: test( tokio:: test) ]
372416 async fn single_worker_gets_no_boundaries ( ) -> Result < ( ) , MySqlError > {
373- let mut db = MockDb { keys : keys ( 1000 ) } ;
417+ let mut db = MockDb :: new ( keys ( 1000 ) ) ;
374418 let count = u64:: cast_from ( db. keys . len ( ) ) ;
375- let boundaries = partition ( & mut db, 1 , count, MIN_BUCKET_ROWS ) . await ?;
419+ let boundaries = partition ( & mut db, 1 , count, MIN_BUCKET_ROWS , u64 :: MAX ) . await ?;
376420 assert ! ( boundaries. is_empty( ) ) ;
377421 Ok ( ( ) )
378422 }
379423
380424 #[ mz_ore:: test( tokio:: test) ]
381425 async fn small_table_gets_no_boundaries ( ) -> Result < ( ) , MySqlError > {
382426 // Under `min_bucket_rows` everything stays in one bucket.
383- let mut db = MockDb { keys : keys ( 10_000 ) } ;
427+ let mut db = MockDb :: new ( keys ( 10_000 ) ) ;
384428 let count = u64:: cast_from ( db. keys . len ( ) ) ;
385- let boundaries = partition ( & mut db, 4 , count, MIN_BUCKET_ROWS ) . await ?;
429+ let boundaries = partition ( & mut db, 4 , count, MIN_BUCKET_ROWS , u64 :: MAX ) . await ?;
386430 assert ! ( boundaries. is_empty( ) ) ;
387431 Ok ( ( ) )
388432 }
389433
390434 #[ mz_ore:: test( tokio:: test) ]
391435 async fn empty_table_gets_no_boundaries ( ) -> Result < ( ) , MySqlError > {
392- let mut db = MockDb { keys : vec ! [ ] } ;
393- let boundaries = partition ( & mut db, 4 , 0 , MIN_BUCKET_ROWS ) . await ?;
436+ let mut db = MockDb :: new ( vec ! [ ] ) ;
437+ let boundaries = partition ( & mut db, 4 , 0 , MIN_BUCKET_ROWS , u64 :: MAX ) . await ?;
394438 assert ! ( boundaries. is_empty( ) ) ;
395439 Ok ( ( ) )
396440 }
397441
398442 #[ mz_ore:: test( tokio:: test) ]
399443 async fn splits_evenly_across_workers ( ) -> Result < ( ) , MySqlError > {
400- let mut db = MockDb {
401- keys : keys ( 200_000 ) ,
402- } ;
444+ let mut db = MockDb :: new ( keys ( 200_000 ) ) ;
403445 let count = u64:: cast_from ( db. keys . len ( ) ) ;
404- let boundaries = partition ( & mut db, 4 , count, MIN_BUCKET_ROWS ) . await ?;
446+ let boundaries = partition ( & mut db, 4 , count, MIN_BUCKET_ROWS , u64 :: MAX ) . await ?;
405447 assert_eq ! ( boundaries. len( ) , 3 ) ;
406448 // Boundaries must be sorted and split the keys into ~50k chunks.
407449 let mut prev = 0 ;
@@ -420,9 +462,9 @@ mod tests {
420462
421463 #[ mz_ore:: test( tokio:: test) ]
422464 async fn low_min_bucket_rows_splits_small_tables ( ) -> Result < ( ) , MySqlError > {
423- let mut db = MockDb { keys : keys ( 1000 ) } ;
465+ let mut db = MockDb :: new ( keys ( 1000 ) ) ;
424466 let count = u64:: cast_from ( db. keys . len ( ) ) ;
425- let boundaries = partition ( & mut db, 4 , count, 10 ) . await ?;
467+ let boundaries = partition ( & mut db, 4 , count, 10 , u64 :: MAX ) . await ?;
426468 assert_eq ! ( boundaries. len( ) , 3 ) ;
427469 let mut prev = 0 ;
428470 for b in & boundaries {
@@ -445,9 +487,9 @@ mod tests {
445487 // range unsplittable at any depth.
446488 let mut all_keys = vec ! [ "U" . to_string( ) ] ;
447489 all_keys. extend ( ( 0 ..1000 ) . map ( |i| format ! ( "U{i:06}" ) ) ) ;
448- let mut db = MockDb { keys : all_keys } ;
490+ let mut db = MockDb :: new ( all_keys) ;
449491 let count = u64:: cast_from ( db. keys . len ( ) ) ;
450- let boundaries = partition ( & mut db, 4 , count, 10 ) . await ?;
492+ let boundaries = partition ( & mut db, 4 , count, 10 , u64 :: MAX ) . await ?;
451493 assert_eq ! ( boundaries. len( ) , 3 ) ;
452494 for b in & boundaries {
453495 assert ! (
@@ -458,6 +500,73 @@ mod tests {
458500 Ok ( ( ) )
459501 }
460502
503+ #[ mz_ore:: test( tokio:: test) ]
504+ async fn request_budget_bounds_probes ( ) -> Result < ( ) , MySqlError > {
505+ // Unlimited budget as a baseline: splitting this table to the target
506+ // costs far more probes than the budget below.
507+ let mut db = MockDb :: new ( keys ( 200_000 ) ) ;
508+ let count = u64:: cast_from ( db. keys . len ( ) ) ;
509+ partition ( & mut db, 4 , count, 10 , u64:: MAX ) . await ?;
510+ assert ! ( db. requests > 50 , "baseline requests={}" , db. requests) ;
511+
512+ // A small budget stops splitting early but still yields a valid,
513+ // ordered boundary list.
514+ let mut db = MockDb :: new ( keys ( 200_000 ) ) ;
515+ let budget = 24 ;
516+ let boundaries = partition ( & mut db, 4 , count, 10 , budget) . await ?;
517+ assert ! ( db. requests <= budget, "requests={}" , db. requests) ;
518+ assert ! (
519+ !boundaries. is_empty( ) && boundaries. len( ) <= 3 ,
520+ "{boundaries:?}"
521+ ) ;
522+ for pair in boundaries. windows ( 2 ) {
523+ assert ! ( pair[ 0 ] < pair[ 1 ] , "{boundaries:?}" ) ;
524+ }
525+ Ok ( ( ) )
526+ }
527+
528+ /// A database whose next-prefix wraps around instead of advancing, like a
529+ /// numeric key column compared as a string. The request budget must still
530+ /// bound the walk, and duplicate boundaries must never be emitted.
531+ struct WrappingDb ;
532+
533+ impl PrimaryKeyProber for WrappingDb {
534+ async fn estimate_range_rows (
535+ & mut self ,
536+ _: Option < & str > ,
537+ _: Option < & str > ,
538+ ) -> Result < u64 , MySqlError > {
539+ Ok ( 1_000_000 )
540+ }
541+ async fn prefix_of_first_key_in_range (
542+ & mut self ,
543+ _: Option < & str > ,
544+ _: Option < & str > ,
545+ _: usize ,
546+ ) -> Result < Option < String > , MySqlError > {
547+ Ok ( Some ( "9" . to_string ( ) ) )
548+ }
549+ async fn prefix_of_first_row_not_matching_prefix (
550+ & mut self ,
551+ _: & str ,
552+ _: Option < & str > ,
553+ _: usize ,
554+ ) -> Result < Option < String > , MySqlError > {
555+ // Never advances past "9".
556+ Ok ( Some ( "1" . to_string ( ) ) )
557+ }
558+ }
559+
560+ #[ mz_ore:: test( tokio:: test) ]
561+ async fn non_advancing_prefixes_terminate ( ) -> Result < ( ) , MySqlError > {
562+ // A finite budget is the only bound here: the wrapping server would
563+ // otherwise feed the walk identical children forever. Boundaries may
564+ // repeat, downstream monotonicity validation rejects them before use.
565+ let boundaries = partition ( & mut WrappingDb , 4 , 1_000_000 , MIN_BUCKET_ROWS , 100 ) . await ?;
566+ assert ! ( boundaries. len( ) <= 3 ) ;
567+ Ok ( ( ) )
568+ }
569+
461570 /// Exercises the partitioner against a live MySQL server, covering what
462571 /// the mock cannot see: `EXPLAIN` estimates over prepared statements,
463572 /// `LIKE` pattern semantics, and the nested next-prefix query.
@@ -514,12 +623,14 @@ mod tests {
514623 } ;
515624
516625 // A minimum above the table size yields no boundaries.
517- let boundaries = partition_table ( & mut conn, table. clone ( ) , "id" , 4 , 1004 , 50_000 ) . await ?;
626+ let boundaries =
627+ partition_table ( & mut conn, table. clone ( ) , "id" , 4 , 1004 , 50_000 , u64:: MAX ) . await ?;
518628 assert ! ( boundaries. is_empty( ) , "{boundaries:?}" ) ;
519629
520630 // A low minimum splits the table, and MySQL agrees the boundaries are
521631 // strictly increasing under the column collation.
522- let boundaries = partition_table ( & mut conn, table. clone ( ) , "id" , 4 , 1004 , 10 ) . await ?;
632+ let boundaries =
633+ partition_table ( & mut conn, table. clone ( ) , "id" , 4 , 1004 , 10 , u64:: MAX ) . await ?;
523634 assert ! (
524635 !boundaries. is_empty( ) && boundaries. len( ) <= 3 ,
525636 "{boundaries:?}"
0 commit comments