Skip to main content

mz_mysql_util/
partition.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use mysql_async::Transaction;
11use mz_ore::cast::CastFrom;
12use mz_ore::str::redact;
13
14use crate::{KeyProber, MySqlError, QualifiedTableRef};
15
16/// Settings for [`partition_table`], bundled to make it harder to accidentally put arguments
17/// in the wrong order.
18pub 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
25/// Computes up to `num_workers - 1` partition boundaries that divide the primary key space
26/// into `num_workers` roughly even partitions. At most `max_probed_prefixes` prefixes are
27/// probed in MySQL to bound the time spent. Each prefix probe costs a few queries that
28/// should each be quick (index dives, instead of table scans).
29///
30/// Nothing here validates the setup: the caller must abide by these
31/// constraints or undefined/untested behavior could occur, e.g. boundaries
32/// that fail to partition the key space or a walk that does not converge.
33/// * `pk_col` is the table's single-column primary key.
34/// * The column type is CHAR or VARCHAR with a declared length of at most
35///   [`crate::probe::MAX_KEY_LENGTH`] characters.
36/// * The column collation is `utf8mb4_bin`.
37/// * The transaction is `REPEATABLE READ`, so the probes (several queries
38///   each) all see one snapshot of the table.
39///
40/// `min_split_threshold` is the smallest estimated row count granularity partitioning will
41/// target, which means if the algorithm processes a prefix estimated to cover less
42/// than min_split_threshold rows it won't bother splitting it up further. This is useful to
43/// avoid unnecessary work for smaller tables limiting the overhead of partitioning.
44pub 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        // The boundaries are user data, redacted outside of CI.
64        boundaries = ?redact(&boundaries),
65        "partitioned table by pk prefix"
66    );
67    Ok(boundaries)
68}
69
70#[derive(Debug)]
71struct Prefix {
72    /// Empty for the beginning of the key space.
73    prefix: String,
74    /// Exclusive end, `None` for the final open prefix.
75    end: Option<String>,
76    /// Row estimate for the prefix, at least 1.
77    estimated_rows: u64,
78    /// Length this prefix was split at.
79    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    // Estimates vary wildly especially near the full table size (see `KeyProber::estimate_range_rows` for more details).
95    // Estimates tend to get more useful as smaller chunks, so break up the table into at least 1/8ths (2 workers * 4)
96    // before selecting partitions. 1/8th was selected by feel due to a couple of observed inaccuracies:
97    // 1. Large estimates were observed as capped at 1/2 the estimated table size when the estimates were big.
98    // 2. Medium or approaching 1/2 estimated table size estimates were observed as large overestimates (~2x)
99    // So, that's a potential 4x swing and then a 2x safety factor to not push too close to the edge.
100    //
101    // Breaking down to smaller partitions results in more accurate splits, so we keep the
102    // 4x multiple of the worker count for > 2 workers. Initial testing was with an 8x multiplier, selected
103    // arbitrarily. From first principles, you can expect that if a prefix containing ~target_max_rows_per_prefix rows
104    // lands right on a boundary (i.e. the worker was 99% full for its range) the worker will get a slot worth
105    // 99% + 1/multiplier (in this case 25%) of the normal worker share resulting in skew with ~124% of the rows it
106    // should own.
107    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    // BFS of prefixes, splitting until estimates fall under the target.
130    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                    // The probe budget ran out mid-walk: drop the partial
148                    // split and keep the parent as a leaf.
149                    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    // Recompute the total after partitioning the table to get more even splits because the actual row count and the
162    // granularly estimated row count can diverge from the original top level estimate.
163    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            // The final prefix's end is None (open), it can never be a boundary.
180            if let Some(end) = &prefix.end {
181                boundaries.push(end.clone());
182            }
183        }
184    }
185    Ok(boundaries)
186}
187
188/// Splits `parent` into prefixes one character longer. i.e. prefix "a", upper bound "b" in table
189/// with pks: ["a", "ab", "abc", "abd", "af", "bb"] will return: ["ab", "af"].
190///
191/// Note: This will drop the key "a" on the floor, along with any keys
192/// sorting below their own prefix (below-space characters at this depth).
193///
194/// `budget` is decremented once per prefix probed. Returns None when it
195/// runs out, discarding the partial walk.
196async 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
234/// Probing operations of [`KeyProber`], as a trait so tests can substitute
235/// an in-memory implementation. See [`KeyProber`]'s methods for each
236/// operation's contract.
237trait 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    /// In-memory [`PrimaryKeyProber`] over a sorted key list with exact
309    /// "estimates". Byte order stands in for the collation, so the PAD SPACE
310    /// below-space cases are deliberately out of scope here, the live tests
311    /// cover them.
312    struct MockDb {
313        keys: Vec<String>,
314        /// Number of requests the real implementation would have made to
315        /// MySQL, per its query pattern at the time of writing.
316        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            // The lower bound is exclusive, a key equal to `start` is skipped.
326            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            // Two requests in the KeyProber implementation at the time of writing.
367            self.requests += 2;
368            let (_, hi) = self.bounds("", end);
369            // Find the last key matching `cur`, byte prefixes stand in for
370            // the collation's LIKE matching.
371            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        // All keys share one depth-1 prefix and fit under `min_rows_per_worker`,
398        // so the single open-ended range yields no boundary.
399        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)] // too slow
416    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        // Boundaries must be sorted and split the keys into ~50k chunks.
422        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        // One key is a bare "U" and every other key extends it. The walk
458        // skips the exact key (exclusive lower bounds) and must keep
459        // splitting inside the extensions at greater depths instead of
460        // stalling on the all-encompassing "U" prefix.
461        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        // count / (workers * 4) is fractional and the minimum is zero, so
479        // the target floors at one row instead of splitting forever.
480        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)] // too slow
488    async fn probe_budget_bounds_requests() -> Result<(), MySqlError> {
489        // Confirms baseline over 200 requests.
490        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        // Confirms limit with budget of 20 is under 80 requests. 4x budget
496        // because we make up to 4 requests per prefix: one to get the first
497        // key matching the current prefix (issued once per split parent,
498        // amortized across its children), two to get the next, and one to
499        // estimate the size.
500        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        // In the unexpected case where there's looping/revisiting we bound the
513        // child walk successfully.
514        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    /// A database whose next-prefix wraps around instead of advancing,
520    /// standing in for corruption or other unexpected server behavior. The
521    /// probe budget must still bound the walk.
522    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            // Never advances past "9".
547            Ok(Some("1".to_string()))
548        }
549    }
550
551    // Live tests against MySQL (when available) for more realistic results.
552
553    #[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        // 10k 4-digit incrementing integer numbers as strings 0000-9999
561        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        // ~1/4 of the table is 2500, so 2000 for some wiggle room
575        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    /// Splitting must reach inside the extensions of the bare key 'a' and
583    /// yield boundaries MySQL agrees are strictly increasing.
584    #[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        // A bare key 'a' that 900 keys extend, 100 keys under 'b', and LIKE
592        // metacharacters.
593        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        // A minimum above the table size yields no boundaries at all.
610        let bounds =
611            partition_table(&mut tx, table.clone(), "id", params(4, total, 50_000)).await?;
612        assert!(bounds.is_empty(), "{bounds:?}");
613
614        // A low minimum splits inside the 'a' extensions rather than stopping
615        // at the exact key.
616        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        // ~1/4 of the table is 250 so 100 leaves lots of room for error.
623        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        // ~10k keys in total, about as many under "c" as the rest combined.
639        // Tabs and empty strings will be dropped, which will show up in the resulting skew.
640        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        // Partition for 4 workers with a minimum split size around 250.
657        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        // ~8k keys are visible, so each count gets at least 2k under perfect
663        // partitioning, and the ranges partition cleanly except for the
664        // hidden tab prefixes. Asserting each count above 1600 makes room
665        // for single partitions being misallocated (~250) and some
666        // inaccuracy on top of that (~150).
667        assert!(counts.iter().all(|&c| c > 1600), "{counts:?}");
668
669        // Each hidden group piles into the partition left of the next visible
670        // boundary, here all of them ('', tabs, b-tabs) land in the first. Keep
671        // the assertion low to ensure there's room for estimate variability.
672        // This is a performance degradation edge case, not a correctness
673        // issue.
674        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    /// Rows per snapshot partition of `bounds`, i.e. the half-open ranges
686    /// `[..b0), [b0, b1), .., [bn, ..)`. The server counts, so the
687    /// comparisons happen under the column's collation.
688    /// Asserts `bounds` are strictly increasing when MySQL compares them under
689    /// `collation`, the same comparison the column's range predicates use. Bare
690    /// `?` parameters would compare under the session collation instead.
691    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    /// Rows per snapshot partition of `bounds`, i.e. the half-open ranges
708    /// `[..b0), [b0, b1), .., [bn, ..)`. The server counts, so the
709    /// comparisons happen under the column's collation. Panics unless `bounds`
710    /// is strictly increasing under it.
711    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            // Checked because `ci` builds have overflow checks off, where a
728            // wrapped count would clear every lower bound the tests assert.
729            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}