1use mysql_async::Transaction;
11use mz_ore::cast::CastFrom;
12use mz_ore::str::redact;
13
14use crate::{KeyProber, MySqlError, QualifiedTableRef};
15
16pub struct PartitionParams {
19 pub num_workers: usize,
20 pub estimated_row_count: u64,
21 pub min_split_threshold: u64,
22 pub max_probed_prefixes: u64,
23}
24
25pub async fn partition_table(
45 tx: &mut Transaction<'_>,
46 table: QualifiedTableRef<'_>,
47 pk_col: &str,
48 params: PartitionParams,
49) -> Result<Vec<String>, MySqlError> {
50 let (schema_name, table_name) = (table.schema_name, table.table_name);
51 let mut db = KeyProber::new(tx, table, pk_col);
52 let boundaries = partition(
53 &mut db,
54 params.num_workers,
55 params.estimated_row_count,
56 params.min_split_threshold,
57 params.max_probed_prefixes,
58 )
59 .await?;
60 tracing::trace!(
61 schema = schema_name,
62 table = table_name,
63 boundaries = ?redact(&boundaries),
65 "partitioned table by pk prefix"
66 );
67 Ok(boundaries)
68}
69
70#[derive(Debug)]
71struct Prefix {
72 prefix: String,
74 end: Option<String>,
76 estimated_rows: u64,
78 depth: usize,
80}
81
82async fn partition<D: PrimaryKeyProber>(
83 db: &mut D,
84 workers: usize,
85 estimated_row_count: u64,
86 min_split_threshold: u64,
87 max_probed_prefixes: u64,
88) -> Result<Vec<String>, MySqlError> {
89 if workers <= 1 {
90 return Ok(Vec::new());
91 }
92 let estimated_row_count = estimated_row_count.max(1);
93
94 let target_max_rows_per_prefix = (estimated_row_count / u64::cast_from(workers * 4))
108 .max(min_split_threshold)
109 .max(1);
110
111 compute_boundaries(
112 db,
113 workers,
114 estimated_row_count,
115 target_max_rows_per_prefix,
116 max_probed_prefixes,
117 )
118 .await
119}
120
121async fn compute_boundaries<D: PrimaryKeyProber>(
122 db: &mut D,
123 workers: usize,
124 estimated_row_count: u64,
125 target_rows_per_prefix: u64,
126 max_probed_prefixes: u64,
127) -> Result<Vec<String>, MySqlError> {
128 let mut budget = max_probed_prefixes;
129 let mut ordered_prefixes = vec![Prefix {
131 prefix: String::new(),
132 end: None,
133 estimated_rows: estimated_row_count,
134 depth: 0,
135 }];
136
137 loop {
138 let mut next_ordered_prefixes: Vec<Prefix> = vec![];
139 let mut split_any = false;
140 for prefix in ordered_prefixes {
141 if prefix.estimated_rows > target_rows_per_prefix && budget > 0 {
142 match children_prefixes(db, &prefix, &mut budget).await? {
143 Some(children) => {
144 split_any = true;
145 next_ordered_prefixes.extend(children);
146 }
147 None => next_ordered_prefixes.push(prefix),
150 }
151 } else {
152 next_ordered_prefixes.push(prefix);
153 }
154 }
155 ordered_prefixes = next_ordered_prefixes;
156 if !split_any {
157 break;
158 }
159 }
160
161 let total: u64 = ordered_prefixes.iter().map(|r| r.estimated_rows).sum();
164 let per_worker = total / u64::cast_from(workers);
165 tracing::debug!(
166 prefixes = ordered_prefixes.len(),
167 total_estimated_rows = total,
168 per_worker,
169 "assigning prefixes to workers"
170 );
171 let mut boundaries: Vec<String> = Vec::with_capacity(workers - 1);
172 let mut rows_seen = 0;
173 for prefix in &ordered_prefixes {
174 if boundaries.len() == workers - 1 {
175 break;
176 }
177 rows_seen += prefix.estimated_rows;
178 if rows_seen >= u64::cast_from(boundaries.len() + 1) * per_worker {
179 if let Some(end) = &prefix.end {
181 boundaries.push(end.clone());
182 }
183 }
184 }
185 Ok(boundaries)
186}
187
188async fn children_prefixes<D: PrimaryKeyProber>(
197 db: &mut D,
198 parent: &Prefix,
199 budget: &mut u64,
200) -> Result<Option<Vec<Prefix>>, MySqlError> {
201 let depth = parent.depth + 1;
202 let mut children = Vec::new();
203
204 let Some(mut cur) = db
205 .prefix_of_first_key_in_range(&parent.prefix, parent.end.as_deref(), depth)
206 .await?
207 else {
208 return Ok(Some(children));
209 };
210
211 loop {
212 if *budget == 0 {
213 return Ok(None);
214 }
215 *budget -= 1;
216 let next = db
217 .prefix_of_first_row_not_matching_prefix(&cur, parent.end.as_deref(), depth)
218 .await?;
219 let end = next.clone().or_else(|| parent.end.clone());
220 let estimated_rows = db.estimate_range_rows(&cur, end.as_deref()).await?;
221 children.push(Prefix {
222 prefix: cur,
223 end,
224 estimated_rows: estimated_rows.max(1),
225 depth,
226 });
227 match next {
228 Some(next) => cur = next,
229 None => return Ok(Some(children)),
230 }
231 }
232}
233
234trait PrimaryKeyProber {
238 async fn estimate_range_rows(
239 &mut self,
240 start: &str,
241 end: Option<&str>,
242 ) -> Result<u64, MySqlError>;
243
244 async fn prefix_of_first_key_in_range(
245 &mut self,
246 start: &str,
247 end: Option<&str>,
248 len: usize,
249 ) -> Result<Option<String>, MySqlError>;
250
251 async fn prefix_of_first_row_not_matching_prefix(
252 &mut self,
253 cur: &str,
254 end: Option<&str>,
255 len: usize,
256 ) -> Result<Option<String>, MySqlError>;
257}
258
259impl<'a, 't> PrimaryKeyProber for KeyProber<'a, 't> {
260 async fn estimate_range_rows(
261 &mut self,
262 start: &str,
263 end: Option<&str>,
264 ) -> Result<u64, MySqlError> {
265 KeyProber::estimate_range_rows(self, start, end).await
266 }
267
268 async fn prefix_of_first_key_in_range(
269 &mut self,
270 start: &str,
271 end: Option<&str>,
272 len: usize,
273 ) -> Result<Option<String>, MySqlError> {
274 KeyProber::prefix_of_first_key_in_range(self, start, end, len).await
275 }
276
277 async fn prefix_of_first_row_not_matching_prefix(
278 &mut self,
279 cur: &str,
280 end: Option<&str>,
281 len: usize,
282 ) -> Result<Option<String>, MySqlError> {
283 KeyProber::prefix_of_first_row_not_matching_prefix(self, cur, end, len).await
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use mysql_async::prelude::Queryable;
290 use mz_ore::cast::CastFrom;
291
292 use super::*;
293 use crate::probe::tests::{connect, drop_db, setup_table, start_tx};
294
295 fn params(
296 num_workers: usize,
297 estimated_row_count: u64,
298 min_split_threshold: u64,
299 ) -> PartitionParams {
300 PartitionParams {
301 num_workers,
302 estimated_row_count,
303 min_split_threshold,
304 max_probed_prefixes: u64::MAX,
305 }
306 }
307
308 struct MockDb {
313 keys: Vec<String>,
314 requests: usize,
317 }
318
319 impl MockDb {
320 fn new(keys: Vec<String>) -> Self {
321 MockDb { keys, requests: 0 }
322 }
323
324 fn bounds(&self, start: &str, end: Option<&str>) -> (usize, usize) {
325 let lo = self.keys.partition_point(|k| k.as_str() <= start);
327 let hi = match end {
328 Some(e) => self.keys.partition_point(|k| k.as_str() < e),
329 None => self.keys.len(),
330 };
331 (lo, hi.max(lo))
332 }
333 }
334
335 impl PrimaryKeyProber for MockDb {
336 async fn estimate_range_rows(
337 &mut self,
338 start: &str,
339 end: Option<&str>,
340 ) -> Result<u64, MySqlError> {
341 self.requests += 1;
342 let (lo, hi) = self.bounds(start, end);
343 Ok(u64::cast_from(hi - lo))
344 }
345
346 async fn prefix_of_first_key_in_range(
347 &mut self,
348 start: &str,
349 end: Option<&str>,
350 len: usize,
351 ) -> Result<Option<String>, MySqlError> {
352 self.requests += 1;
353 let (lo, hi) = self.bounds(start, end);
354 if lo >= hi {
355 return Ok(None);
356 }
357 Ok(Some(self.keys[lo].chars().take(len).collect()))
358 }
359
360 async fn prefix_of_first_row_not_matching_prefix(
361 &mut self,
362 cur: &str,
363 end: Option<&str>,
364 len: usize,
365 ) -> Result<Option<String>, MySqlError> {
366 self.requests += 2;
368 let (_, hi) = self.bounds("", end);
369 let Some(last_match) = self.keys[..hi].iter().rposition(|k| k.starts_with(cur)) else {
372 return Ok(None);
373 };
374 Ok(self.keys[last_match + 1..hi]
375 .first()
376 .map(|k| k.chars().take(len).collect()))
377 }
378 }
379
380 fn keys(n: usize) -> Vec<String> {
381 (0..n).map(|i| format!("{i:06}")).collect()
382 }
383
384 const MIN_ROWS_PER_WORKER: u64 = 50_000;
385
386 #[mz_ore::test(tokio::test)]
387 async fn single_worker_gets_no_boundaries() -> Result<(), MySqlError> {
388 let mut db = MockDb::new(keys(1000));
389 let count = u64::cast_from(db.keys.len());
390 let boundaries = partition(&mut db, 1, count, MIN_ROWS_PER_WORKER, u64::MAX).await?;
391 assert!(boundaries.is_empty());
392 Ok(())
393 }
394
395 #[mz_ore::test(tokio::test)]
396 async fn small_table_gets_no_boundaries() -> Result<(), MySqlError> {
397 let mut db = MockDb::new(keys(10_000));
400 let count = u64::cast_from(db.keys.len());
401 let boundaries = partition(&mut db, 4, count, MIN_ROWS_PER_WORKER, u64::MAX).await?;
402 assert!(boundaries.is_empty());
403 Ok(())
404 }
405
406 #[mz_ore::test(tokio::test)]
407 async fn empty_table_gets_no_boundaries() -> Result<(), MySqlError> {
408 let mut db = MockDb::new(vec![]);
409 let boundaries = partition(&mut db, 4, 0, MIN_ROWS_PER_WORKER, u64::MAX).await?;
410 assert!(boundaries.is_empty());
411 Ok(())
412 }
413
414 #[mz_ore::test(tokio::test)]
415 #[cfg_attr(miri, ignore)] async fn splits_evenly_across_workers() -> Result<(), MySqlError> {
417 let mut db = MockDb::new(keys(200_000));
418 let count = u64::cast_from(db.keys.len());
419 let boundaries = partition(&mut db, 4, count, MIN_ROWS_PER_WORKER, u64::MAX).await?;
420 assert_eq!(boundaries.len(), 3);
421 let mut prev = 0;
423 for b in &boundaries {
424 let idx = db.keys.partition_point(|k| k.as_str() < b.as_str());
425 let share = idx - prev;
426 assert!(
427 (40_000..=60_000).contains(&share),
428 "uneven share {share} at boundary {b:?} (all: {boundaries:?})",
429 );
430 prev = idx;
431 }
432 assert!((40_000..=60_000).contains(&(db.keys.len() - prev)));
433 Ok(())
434 }
435
436 #[mz_ore::test(tokio::test)]
437 async fn low_min_rows_per_worker_splits_small_tables() -> Result<(), MySqlError> {
438 let mut db = MockDb::new(keys(1000));
439 let count = u64::cast_from(db.keys.len());
440 let boundaries = partition(&mut db, 4, count, 10, u64::MAX).await?;
441 assert_eq!(boundaries.len(), 3);
442 let mut prev = 0;
443 for b in &boundaries {
444 let idx = db.keys.partition_point(|k| k.as_str() < b.as_str());
445 let share = idx - prev;
446 assert!(
447 (150..=350).contains(&share),
448 "uneven share {share} at boundary {b:?} (all: {boundaries:?})",
449 );
450 prev = idx;
451 }
452 Ok(())
453 }
454
455 #[mz_ore::test(tokio::test)]
456 async fn short_key_does_not_block_splitting() -> Result<(), MySqlError> {
457 let mut all_keys = vec!["U".to_string()];
462 all_keys.extend((0..1000).map(|i| format!("U{i:06}")));
463 let mut db = MockDb::new(all_keys);
464 let count = u64::cast_from(db.keys.len());
465 let boundaries = partition(&mut db, 4, count, 10, u64::MAX).await?;
466 assert_eq!(boundaries.len(), 3);
467 for b in &boundaries {
468 assert!(
469 b.starts_with('U') && b.len() > 1,
470 "boundary {b:?} does not subdivide the extensions (all: {boundaries:?})"
471 );
472 }
473 Ok(())
474 }
475
476 #[mz_ore::test(tokio::test)]
477 async fn fractional_target_still_terminates() -> Result<(), MySqlError> {
478 let mut db = MockDb::new(keys(3));
481 let boundaries = partition(&mut db, 4, 3, 0, u64::MAX).await?;
482 assert_eq!(boundaries, vec!["000001", "000002"]);
483 Ok(())
484 }
485
486 #[mz_ore::test(tokio::test)]
487 #[cfg_attr(miri, ignore)] async fn probe_budget_bounds_requests() -> Result<(), MySqlError> {
489 let mut db = MockDb::new(keys(200_000));
491 let count = u64::cast_from(db.keys.len());
492 partition(&mut db, 16, count, 10, u64::MAX).await?;
493 assert!(db.requests > 200, "baseline requests={}", db.requests);
494
495 let mut db = MockDb::new(keys(200_000));
501 let budget = 20;
502 let boundaries = partition(&mut db, 16, count, 10, budget).await?;
503 assert!(db.requests <= 80, "requests={}", db.requests);
504 for pair in boundaries.windows(2) {
505 assert!(pair[0] < pair[1], "{boundaries:?}");
506 }
507 Ok(())
508 }
509
510 #[mz_ore::test(tokio::test)]
511 async fn non_advancing_prefixes_terminate() -> Result<(), MySqlError> {
512 let boundaries = partition(&mut WrappingDb, 4, 1_000_000, MIN_ROWS_PER_WORKER, 100).await?;
515 assert!(boundaries.len() <= 3);
516 Ok(())
517 }
518
519 struct WrappingDb;
523
524 impl PrimaryKeyProber for WrappingDb {
525 async fn estimate_range_rows(
526 &mut self,
527 _: &str,
528 _: Option<&str>,
529 ) -> Result<u64, MySqlError> {
530 Ok(1_000_000)
531 }
532 async fn prefix_of_first_key_in_range(
533 &mut self,
534 _: &str,
535 _: Option<&str>,
536 _: usize,
537 ) -> Result<Option<String>, MySqlError> {
538 Ok(Some("9".to_string()))
539 }
540 async fn prefix_of_first_row_not_matching_prefix(
541 &mut self,
542 _: &str,
543 _: Option<&str>,
544 _: usize,
545 ) -> Result<Option<String>, MySqlError> {
546 Ok(Some("1".to_string()))
548 }
549 }
550
551 #[mz_ore::test(tokio::test)]
554 #[cfg_attr(miri, ignore)]
555 async fn basic_partitioning() -> Result<(), anyhow::Error> {
556 let Some(mut conn) = connect().await? else {
557 return Ok(());
558 };
559
560 let mut all_keys = vec![];
562 all_keys.extend((0..10000).map(|i| format!("{i:04}")));
563
564 const DB: &str = "mz_partition_basic_test";
565 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &all_keys).await?;
566 let total = u64::cast_from(all_keys.len());
567
568 let mut tx = start_tx(&mut conn).await?;
569 let bounds = partition_table(&mut tx, table.clone(), "id", params(4, total, 100)).await?;
570 tx.rollback().await?;
571 assert_eq!(bounds.len(), 3, "{bounds:?}");
572 assert_bounds_increasing(&mut conn, &bounds, "utf8mb4_bin").await?;
573 let counts = partition_counts(&mut conn, DB, &bounds, total).await?;
574 assert!(counts.iter().all(|&c| c > 2000), "{counts:?}");
576
577 drop_db(&mut conn, DB).await?;
578 conn.disconnect().await?;
579 Ok(())
580 }
581
582 #[mz_ore::test(tokio::test)]
585 #[cfg_attr(miri, ignore)]
586 async fn skewed_partitions_with_wildcards_and_short_keys() -> Result<(), anyhow::Error> {
587 let Some(mut conn) = connect().await? else {
588 return Ok(());
589 };
590
591 let mut all_keys = vec![
594 "a".to_string(),
595 "c_1".to_string(),
596 "c%2".to_string(),
597 "c\\3".to_string(),
598 "c|4".to_string(),
599 ];
600 all_keys.extend((0..900).map(|i| format!("a{i:05}")));
601 all_keys.extend((0..100).map(|i| format!("b{i:05}")));
602
603 const DB: &str = "mz_partition_test";
604 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &all_keys).await?;
605 let total = u64::cast_from(all_keys.len());
606
607 let mut tx = start_tx(&mut conn).await?;
608
609 let bounds =
611 partition_table(&mut tx, table.clone(), "id", params(4, total, 50_000)).await?;
612 assert!(bounds.is_empty(), "{bounds:?}");
613
614 let bounds = partition_table(&mut tx, table, "id", params(4, total, 10)).await?;
617 tx.rollback().await?;
618 assert_eq!(bounds.len(), 3, "{bounds:?}");
619
620 assert_bounds_increasing(&mut conn, &bounds, "utf8mb4_bin").await?;
621 let counts = partition_counts(&mut conn, DB, &bounds, total).await?;
622 assert!(counts.iter().all(|&c| c > 100), "{counts:?}");
624
625 drop_db(&mut conn, DB).await?;
626 conn.disconnect().await?;
627 Ok(())
628 }
629
630 #[mz_ore::test(tokio::test)]
631 #[cfg_attr(miri, ignore)]
632 async fn skew_empty_string_and_below_space_characters_inaccuracy() -> Result<(), anyhow::Error>
633 {
634 let Some(mut conn) = connect().await? else {
635 return Ok(());
636 };
637
638 let mut all_keys = vec![String::new()];
641 add_1k_keys(&mut all_keys, "\t");
642 add_1k_keys(&mut all_keys, "a");
643 add_1k_keys(&mut all_keys, "b");
644 add_1k_keys(&mut all_keys, "b\t");
645 add_1k_keys(&mut all_keys, "c");
646 add_1k_keys(&mut all_keys, "ca");
647 add_1k_keys(&mut all_keys, "cb");
648 add_1k_keys(&mut all_keys, "cc");
649 add_1k_keys(&mut all_keys, "cd");
650 add_1k_keys(&mut all_keys, "d");
651
652 const DB: &str = "mz_partition_live_mixed_test";
653 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &all_keys).await?;
654 let total = u64::cast_from(all_keys.len());
655
656 let mut tx = start_tx(&mut conn).await?;
658 let bounds = partition_table(&mut tx, table, "id", params(4, total, 250)).await?;
659 tx.rollback().await?;
660 assert_eq!(bounds.len(), 3);
661 let counts = partition_counts(&mut conn, DB, &bounds, total).await?;
662 assert!(counts.iter().all(|&c| c > 1600), "{counts:?}");
668
669 assert!(counts[0] > 2600, "{counts:?}");
675
676 drop_db(&mut conn, DB).await?;
677 conn.disconnect().await?;
678 Ok(())
679 }
680
681 fn add_1k_keys(all_keys: &mut Vec<String>, prefix: &str) {
682 all_keys.extend((0..1000).map(|i| format!("{prefix}{i:03}")));
683 }
684
685 async fn assert_bounds_increasing(
692 conn: &mut mysql_async::Conn,
693 bounds: &[String],
694 collation: &str,
695 ) -> Result<(), anyhow::Error> {
696 let charset = collation.split('_').next().expect("nonempty collation");
697 let term = format!("CONVERT(? USING {charset}) COLLATE {collation}");
698 for pair in bounds.windows(2) {
699 let increasing: Option<i64> = conn
700 .exec_first(format!("SELECT {term} < {term}"), (&pair[0], &pair[1]))
701 .await?;
702 assert_eq!(increasing, Some(1), "{bounds:?}");
703 }
704 Ok(())
705 }
706
707 async fn partition_counts(
712 conn: &mut mysql_async::Conn,
713 db: &str,
714 bounds: &[String],
715 total: u64,
716 ) -> Result<Vec<u64>, anyhow::Error> {
717 let mut counts = Vec::with_capacity(bounds.len() + 1);
718 let mut below = 0;
719 for bound in bounds {
720 let cumulative: Option<u64> = conn
721 .exec_first(
722 format!("SELECT COUNT(*) FROM {db}.t WHERE id < ?"),
723 (bound.as_str(),),
724 )
725 .await?;
726 let cumulative = cumulative.expect("COUNT returns a row");
727 counts.push(cumulative.checked_sub(below).expect("increasing bounds"));
730 below = cumulative;
731 }
732 counts.push(total.checked_sub(below).expect("increasing bounds"));
733 Ok(counts)
734 }
735}