1use mysql_async::prelude::Queryable;
11use mysql_async::{Params, Transaction, Value};
12use mz_ore::str::redact;
13
14use crate::{MySqlError, QualifiedTableRef, quote_identifier};
15
16const LIKE_ESCAPE: char = '|';
18
19pub const MAX_KEY_LENGTH: u32 = 768;
24
25pub struct KeyProber<'a, 't> {
33 tx: &'a mut Transaction<'t>,
34 table: String,
36 col: String,
38 table_name: String,
40 col_name: String,
42}
43
44impl<'a, 't> KeyProber<'a, 't> {
45 pub fn new(tx: &'a mut Transaction<'t>, table: QualifiedTableRef<'_>, key_col: &str) -> Self {
50 Self {
51 tx,
52 table: format!(
53 "{}.{}",
54 quote_identifier(table.schema_name),
55 quote_identifier(table.table_name)
56 ),
57 col: quote_identifier(key_col),
58 table_name: format!("{}.{}", table.schema_name, table.table_name),
59 col_name: key_col.to_string(),
60 }
61 }
62
63 pub async fn estimate_range_rows(
73 &mut self,
74 lower_bound_exclusive: &str,
75 upper_bound_exclusive: Option<&str>,
76 ) -> Result<u64, MySqlError> {
77 let (clause, params) =
78 self.range_filter(Some(lower_bound_exclusive), upper_bound_exclusive);
79 let select = format!(
80 "SELECT {col} FROM {table} WHERE {clause}",
81 col = self.col,
82 table = self.table,
83 );
84 explain_row_estimate(&mut *self.tx, &select, Params::Positional(params))
85 .await?
86 .ok_or_else(|| MySqlError::MissingRowEstimate {
87 qualified_table_name: self.table_name.clone(),
88 lower_bound: format!("{:?}", redact(&lower_bound_exclusive)),
91 upper_bound: format!("{:?}", redact(&upper_bound_exclusive)),
92 })
93 }
94
95 pub async fn prefix_of_first_key_in_range(
108 &mut self,
109 lower_bound_exclusive: &str,
110 upper_bound_exclusive: Option<&str>,
111 max_prefix_length: usize,
112 ) -> Result<Option<String>, MySqlError> {
113 let (clause, params) =
114 self.range_filter(Some(lower_bound_exclusive), upper_bound_exclusive);
115 let sql = format!(
116 "SELECT LEFT({col}, {max_prefix_length}) FROM {table} WHERE {clause} ORDER BY {col} LIMIT 1",
117 col = self.col,
118 table = self.table,
119 );
120 self.query_string(sql, params).await
121 }
122
123 pub async fn prefix_of_first_row_not_matching_prefix(
129 &mut self,
130 prefix: &str,
131 upper_bound_exclusive: Option<&str>,
132 max_prefix_length: usize,
133 ) -> Result<Option<String>, MySqlError> {
134 let Some(max_key) = self
135 .max_key_with_prefix(prefix, upper_bound_exclusive)
136 .await?
137 else {
138 return Ok(None);
139 };
140 self.prefix_of_first_key_in_range(&max_key, upper_bound_exclusive, max_prefix_length)
141 .await
142 }
143
144 async fn max_key_with_prefix(
156 &mut self,
157 prefix: &str,
158 upper_bound_exclusive: Option<&str>,
159 ) -> Result<Option<String>, MySqlError> {
160 let (range_clause, range_params) = self.range_filter(None, upper_bound_exclusive);
161 let sql = format!(
162 "SELECT {col} FROM {table} WHERE {col} LIKE ? ESCAPE '{LIKE_ESCAPE}' \
163 AND {range_clause} ORDER BY {col} DESC LIMIT 1",
164 col = self.col,
165 table = self.table,
166 );
167 let mut params: Vec<Value> = vec![like_prefix_pattern(prefix).into()];
168 params.extend(range_params);
169 self.query_string(sql, params).await
170 }
171
172 fn range_filter(
184 &self,
185 lower_bound_exclusive: Option<&str>,
186 upper_bound_exclusive: Option<&str>,
187 ) -> (String, Vec<Value>) {
188 let col = &self.col;
189 let mut conditions = Vec::new();
190 let mut params: Vec<Value> = Vec::new();
191 if let Some(lower) = lower_bound_exclusive {
192 conditions.push(format!("{col} > ?"));
193 params.push(lower.into());
194 }
195 if let Some(upper) = upper_bound_exclusive {
196 conditions.push(format!(
197 "{col} < RPAD(?, {MAX_KEY_LENGTH}, CHAR(0 USING utf8mb4))"
198 ));
199 params.push(upper.into());
200 }
201 if conditions.is_empty() {
202 ("TRUE".to_string(), params)
203 } else {
204 (conditions.join(" AND "), params)
205 }
206 }
207
208 async fn query_string(
209 &mut self,
210 sql: String,
211 params: Vec<Value>,
212 ) -> Result<Option<String>, MySqlError> {
213 let row: Option<mysql_async::Row> =
214 self.tx.exec_first(sql, Params::Positional(params)).await?;
215 match row.and_then(|mut row| row.take_opt::<String, _>(0)) {
216 None => Ok(None),
217 Some(Ok(value)) => Ok(Some(value)),
218 Some(Err(err)) => Err(MySqlError::NonUtf8KeyValue {
219 qualified_table_name: self.table_name.clone(),
220 column_name: self.col_name.clone(),
221 error: err.to_string(),
222 }),
223 }
224 }
225}
226
227fn like_prefix_pattern(prefix: &str) -> String {
232 let mut pattern = String::with_capacity(prefix.len() + 1);
233 for c in prefix.chars() {
234 if c == LIKE_ESCAPE || matches!(c, '%' | '_') {
235 pattern.push(LIKE_ESCAPE);
236 }
237 pattern.push(c);
238 }
239 pattern.push('%');
240 pattern
241}
242
243async fn explain_row_estimate<P>(
244 tx: &mut Transaction<'_>,
245 select: &str,
246 params: P,
247) -> Result<Option<u64>, MySqlError>
248where
249 P: Into<Params> + Send,
250{
251 let plan: Option<mysql_async::Row> = tx
252 .exec_first(format!("EXPLAIN FORMAT=TRADITIONAL {select}"), params)
253 .await?;
254 let estimate = plan.and_then(|row| {
255 row.get_opt::<Option<u64>, _>("rows")
256 .and_then(Result::ok)
257 .flatten()
258 });
259 Ok(estimate)
260}
261
262#[cfg(test)]
264pub(crate) mod tests {
265 use std::collections::BTreeSet;
266
267 use mz_ore::cast::CastFrom;
268
269 use super::*;
270
271 #[mz_ore::test]
272 fn test_like_prefix_pattern() {
273 assert_eq!(like_prefix_pattern("abc"), "abc%");
274 assert_eq!(like_prefix_pattern(""), "%");
275 assert_eq!(like_prefix_pattern("a_b"), "a|_b%");
276 assert_eq!(like_prefix_pattern("50%"), "50|%%");
277 assert_eq!(like_prefix_pattern("a|b"), "a||b%");
278 assert_eq!(like_prefix_pattern("a\\b"), "a\\b%");
280 assert_eq!(like_prefix_pattern("héllo"), "héllo%");
281 }
282
283 #[mz_ore::test(tokio::test)]
284 #[cfg_attr(miri, ignore)]
285 async fn test_basic_prefix_traversal() -> Result<(), anyhow::Error> {
286 let Some(mut conn) = connect().await? else {
287 return Ok(());
288 };
289 const DB: &str = "mz_probe_basic";
290 let keys = ["aa", "ab", "b", "bb", "bbb", "c"];
291 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &keys).await?;
292
293 let mut tx = start_tx(&mut conn).await?;
294 let p = &mut KeyProber::new(&mut tx, table, "id");
295 assert_eq!(
296 prefix_of_first_key_in_range(p, "", None, 1).await,
297 some("a")
298 );
299 assert_eq!(
300 prefix_of_first_row_not_matching_prefix(p, "a", None, 1).await,
301 some("b")
302 );
303 assert_eq!(
304 prefix_of_first_row_not_matching_prefix(p, "b", None, 1).await,
305 some("c")
306 );
307 assert_eq!(
308 prefix_of_first_row_not_matching_prefix(p, "c", None, 1).await,
309 None
310 );
311
312 assert_eq!(
313 prefix_of_first_key_in_range(p, "a", Some("b"), 2).await,
314 some("aa")
315 );
316 assert_eq!(
317 prefix_of_first_row_not_matching_prefix(p, "aa", Some("b"), 2).await,
318 some("ab")
319 );
320 assert_eq!(
321 prefix_of_first_row_not_matching_prefix(p, "ab", Some("b"), 2).await,
322 None
323 );
324
325 assert_eq!(
328 prefix_of_first_key_in_range(p, "b", Some("c"), 2).await,
329 some("bb")
330 );
331 assert_eq!(
332 prefix_of_first_row_not_matching_prefix(p, "bb", Some("c"), 2).await,
333 None
334 );
335 assert_eq!(prefix_of_first_key_in_range(p, "c", None, 2).await, None);
336
337 tx.rollback().await?;
338 drop_db(&mut conn, DB).await?;
339 conn.disconnect().await?;
340 Ok(())
341 }
342
343 #[mz_ore::test(tokio::test)]
344 #[cfg_attr(miri, ignore)]
345 async fn test_explain_row_estimate_sizing() -> Result<(), anyhow::Error> {
346 let Some(mut conn) = connect().await? else {
347 return Ok(());
348 };
349 const DB: &str = "mz_probe_explain_test";
350 let ids: Vec<String> = (0..1000).map(|i| format!("a{i:05}")).collect();
351 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &ids).await?;
352 let mut tx = start_tx(&mut conn).await?;
353 let mut p = KeyProber::new(&mut tx, table, "id");
354
355 let all = p.estimate_range_rows("", None).await?;
358 assert!((500..=2000).contains(&all), "all={all}");
359 let half = p.estimate_range_rows("a00500", None).await?;
360 assert!((250..=1000).contains(&half), "half={half}");
361 let none = p.estimate_range_rows("zzz", None).await?;
362 assert!(none <= 5, "none={none}");
363
364 tx.rollback().await?;
365 drop_db(&mut conn, DB).await?;
366 conn.disconnect().await?;
367 Ok(())
368 }
369
370 #[mz_ore::test(tokio::test)]
371 #[cfg_attr(miri, ignore)]
372 async fn test_case_insensitive_prefix_traversal() -> Result<(), anyhow::Error> {
373 let Some(mut conn) = connect().await? else {
374 return Ok(());
375 };
376 const DB: &str = "mz_probe_case_insensitive";
377 let keys = ["Aa", "ab", "b", "Bb", "bbb", "C"];
378 let table = setup_table(&mut conn, DB, "utf8mb4_general_ci", &keys).await?;
379
380 let mut tx = start_tx(&mut conn).await?;
381 let p = &mut KeyProber::new(&mut tx, table, "id");
382 assert_eq!(
386 prefix_of_first_key_in_range(p, "", None, 1).await,
387 some("A")
388 );
389 assert_eq!(
391 prefix_of_first_row_not_matching_prefix(p, "A", None, 1).await,
392 some("b")
393 );
394 assert_eq!(
395 prefix_of_first_row_not_matching_prefix(p, "b", None, 1).await,
396 some("C")
397 );
398 assert_eq!(
399 prefix_of_first_row_not_matching_prefix(p, "C", None, 1).await,
400 None
401 );
402
403 assert_eq!(
405 prefix_of_first_key_in_range(p, "A", Some("b"), 2).await,
406 some("Aa")
407 );
408 assert_eq!(
409 prefix_of_first_row_not_matching_prefix(p, "Aa", Some("b"), 2).await,
410 some("ab")
411 );
412 assert_eq!(
413 prefix_of_first_row_not_matching_prefix(p, "ab", Some("b"), 2).await,
414 None
415 );
416
417 assert_eq!(
421 prefix_of_first_key_in_range(p, "b", Some("C"), 2).await,
422 some("Bb")
423 );
424 assert_eq!(
425 prefix_of_first_row_not_matching_prefix(p, "Bb", Some("C"), 2).await,
426 None
427 );
428 assert_eq!(
430 prefix_of_first_row_not_matching_prefix(p, "b", Some("C"), 2).await,
431 None
432 );
433
434 assert_eq!(prefix_of_first_key_in_range(p, "C", None, 2).await, None);
435
436 tx.rollback().await?;
437 drop_db(&mut conn, DB).await?;
438 conn.disconnect().await?;
439 Ok(())
440 }
441
442 #[mz_ore::test(tokio::test)]
443 #[cfg_attr(miri, ignore)]
444 async fn test_wildcard_char_in_data() -> Result<(), anyhow::Error> {
445 let Some(mut conn) = connect().await? else {
446 return Ok(());
447 };
448 const DB: &str = "mz_probe_wildcard_test";
449 let keys = ["a_1", "a\\2", "a\\3", "a%4", "a|5"];
453 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &keys).await?;
454
455 let mut tx = start_tx(&mut conn).await?;
456 let p = &mut KeyProber::new(&mut tx, table, "id");
457 assert_eq!(
458 prefix_of_first_key_in_range(p, "", None, 1).await,
459 some("a")
460 );
461 assert_eq!(
462 prefix_of_first_row_not_matching_prefix(p, "a", None, 1).await,
463 None
464 );
465 assert_eq!(
466 prefix_of_first_key_in_range(p, "a", None, 2).await,
467 some("a%")
468 );
469 assert_eq!(
470 prefix_of_first_row_not_matching_prefix(p, "a%", None, 2).await,
471 some("a\\")
472 );
473 assert_eq!(
474 prefix_of_first_row_not_matching_prefix(p, "a\\", None, 2).await,
475 some("a_")
476 );
477 assert_eq!(
478 prefix_of_first_row_not_matching_prefix(p, "a_", None, 2).await,
479 some("a|")
480 );
481 assert_eq!(
482 prefix_of_first_row_not_matching_prefix(p, "a|", None, 2).await,
483 None
484 );
485
486 assert_eq!(
488 prefix_of_first_key_in_range(p, "a%", Some("a\\"), 3).await,
489 some("a%4")
490 );
491 assert_eq!(
492 prefix_of_first_row_not_matching_prefix(p, "a%4", Some("a\\"), 3).await,
493 None
494 );
495 assert_eq!(
496 prefix_of_first_key_in_range(p, "a\\", Some("a_"), 3).await,
497 some("a\\2")
498 );
499 assert_eq!(
500 prefix_of_first_row_not_matching_prefix(p, "a\\2", Some("a_"), 3).await,
501 some("a\\3")
502 );
503 assert_eq!(
504 prefix_of_first_row_not_matching_prefix(p, "a\\3", Some("a_"), 3).await,
505 None
506 );
507 assert_eq!(
508 prefix_of_first_key_in_range(p, "a_", Some("a|"), 3).await,
509 some("a_1")
510 );
511 assert_eq!(
512 prefix_of_first_row_not_matching_prefix(p, "a_1", None, 3).await,
513 some("a|5")
514 );
515 assert_eq!(
516 prefix_of_first_key_in_range(p, "a|", None, 3).await,
517 some("a|5")
518 );
519 assert_eq!(
520 prefix_of_first_row_not_matching_prefix(p, "a|5", None, 3).await,
521 None
522 );
523
524 tx.rollback().await?;
525 drop_db(&mut conn, DB).await?;
526 conn.disconnect().await?;
527 Ok(())
528 }
529
530 #[mz_ore::test(tokio::test)]
531 #[cfg_attr(miri, ignore)]
532 async fn test_multibyte_chars_in_data() -> Result<(), anyhow::Error> {
533 let Some(mut conn) = connect().await? else {
534 return Ok(());
535 };
536 const DB: &str = "mz_probe_multibyte_test";
537 let keys = ["a", "a😀", "日本", "日本語", "😀", "😀a", "😀😀"];
540 let table = setup_table(&mut conn, DB, "utf8mb4_general_ci", &keys).await?;
541
542 let mut tx = start_tx(&mut conn).await?;
543 let p = &mut KeyProber::new(&mut tx, table, "id");
544 assert_eq!(
547 prefix_of_first_key_in_range(p, "", None, 1).await,
548 some("a")
549 );
550 assert_eq!(
551 prefix_of_first_row_not_matching_prefix(p, "a", None, 1).await,
552 some("日")
553 );
554 assert_eq!(
555 prefix_of_first_row_not_matching_prefix(p, "日", None, 1).await,
556 some("😀")
557 );
558 assert_eq!(
559 prefix_of_first_row_not_matching_prefix(p, "😀", None, 1).await,
560 None
561 );
562
563 assert_eq!(
565 prefix_of_first_key_in_range(p, "a", Some("日"), 2).await,
566 some("a😀")
567 );
568 assert_eq!(
569 prefix_of_first_row_not_matching_prefix(p, "a😀", Some("日"), 2).await,
570 None
571 );
572 assert_eq!(
573 prefix_of_first_key_in_range(p, "日", Some("😀"), 2).await,
574 some("日本")
575 );
576 assert_eq!(
577 prefix_of_first_row_not_matching_prefix(p, "日本", Some("😀"), 2).await,
578 None
579 );
580 assert_eq!(
581 prefix_of_first_key_in_range(p, "😀", None, 2).await,
582 some("😀a")
583 );
584 assert_eq!(
585 prefix_of_first_row_not_matching_prefix(p, "😀a", None, 2).await,
586 some("😀😀")
587 );
588 assert_eq!(
589 prefix_of_first_row_not_matching_prefix(p, "😀😀", None, 2).await,
590 None
591 );
592
593 tx.rollback().await?;
594 drop_db(&mut conn, DB).await?;
595 conn.disconnect().await?;
596 Ok(())
597 }
598
599 #[mz_ore::test(tokio::test)]
600 #[cfg_attr(miri, ignore)]
601 async fn test_live_mysql_uuid_pk() -> Result<(), anyhow::Error> {
602 let Some(mut conn) = connect().await? else {
603 return Ok(());
604 };
605 const DB: &str = "mz_probe_uuid_test";
606 let ids: Vec<String> = (0..1000u64)
609 .map(|i| {
610 let h = i.wrapping_mul(2654435761) % 0x1_0000_0000;
611 format!("{h:08x}-0000-4000-8000-{i:012x}")
612 })
613 .collect();
614 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &ids).await?;
615 let mut tx = start_tx(&mut conn).await?;
616 let mut p = KeyProber::new(&mut tx, table, "id");
617
618 assert_eq!(
620 prefix_of_first_key_in_range(&mut p, "", None, 36).await,
621 ids.iter().min().cloned()
622 );
623
624 let walked = walk_prefixes(&mut p, 1).await?;
625 let expected: Vec<String> = ids
626 .iter()
627 .map(|id| id[..1].to_string())
628 .collect::<BTreeSet<_>>()
629 .into_iter()
630 .collect();
631 assert_eq!(walked, expected);
632
633 tx.rollback().await?;
634 drop_db(&mut conn, DB).await?;
635 conn.disconnect().await?;
636 Ok(())
637 }
638
639 #[mz_ore::test(tokio::test)]
640 #[cfg_attr(miri, ignore)]
641 async fn test_live_mysql_like_metacharacters() -> Result<(), anyhow::Error> {
642 let Some(mut conn) = connect().await? else {
643 return Ok(());
644 };
645 const DB: &str = "mz_probe_like_test";
646 let ids = [
649 "%a",
650 "%%",
651 "_a",
652 "__",
653 "\\a",
654 "\\\\",
655 "a%",
656 "a%b",
657 "a_",
658 "a_b",
659 "a\\",
660 "a\\b",
661 "ab",
662 "a b",
663 "|a",
664 "||",
665 "a|",
666 "a|b",
667 "100%",
668 "50%off",
669 "under_score",
670 "back\\slash",
671 ];
672 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &ids).await?;
673
674 for len in [1, 2] {
677 let mut tx = start_tx(&mut conn).await?;
678 let walked =
679 walk_prefixes(&mut KeyProber::new(&mut tx, table.clone(), "id"), len).await?;
680 tx.rollback().await?;
681 let mut total = 0;
682 for (i, lo) in walked.iter().enumerate() {
683 let (n, prefixed) = count_range(&mut conn, DB, lo, walked.get(i + 1)).await?;
684 assert!(
685 n > 0,
686 "empty interval: len={len} lo={lo:?} walked={walked:?}"
687 );
688 assert_eq!(
689 prefixed, n,
690 "keys outside prefix: len={len} lo={lo:?} walked={walked:?}"
691 );
692 total += n;
693 }
694 assert_eq!(
695 total,
696 u64::cast_from(ids.len()),
697 "len={len} walked={walked:?}"
698 );
699 }
700
701 drop_db(&mut conn, DB).await?;
702 conn.disconnect().await?;
703 Ok(())
704 }
705
706 #[mz_ore::test(tokio::test)]
707 #[cfg_attr(miri, ignore)]
708 async fn test_live_mysql_collations() -> Result<(), anyhow::Error> {
709 let Some(mut conn) = connect().await? else {
710 return Ok(());
711 };
712 const CI_DB: &str = "mz_probe_collation_ci_test";
713 const BIN_DB: &str = "mz_probe_collation_bin_test";
714
715 let ci_keys = ["Apple", "apricot", "banana", "Cherry"];
718 let t_ci = setup_table(&mut conn, CI_DB, "utf8mb4_general_ci", &ci_keys).await?;
719 let mut tx = start_tx(&mut conn).await?;
720 let mut prober = KeyProber::new(&mut tx, t_ci, "id");
721 assert_eq!(walk_prefixes(&mut prober, 1).await?, ["A", "b", "C"]);
724 tx.rollback().await?;
725
726 let bin_keys = ["ABC", "ABD", "abc", "abd"];
728 let t_bin = setup_table(&mut conn, BIN_DB, "utf8mb4_bin", &bin_keys).await?;
729 let mut tx = start_tx(&mut conn).await?;
730 let mut prober = KeyProber::new(&mut tx, t_bin, "id");
731 assert_eq!(walk_prefixes(&mut prober, 1).await?, ["A", "a"]);
734 assert_eq!(walk_prefixes(&mut prober, 3).await?, bin_keys);
735 tx.rollback().await?;
736
737 drop_db(&mut conn, CI_DB).await?;
738 drop_db(&mut conn, BIN_DB).await?;
739 conn.disconnect().await?;
740 Ok(())
741 }
742
743 #[mz_ore::test(tokio::test)]
744 #[cfg_attr(miri, ignore)]
745 async fn test_live_mysql_invalid_utf8_keys() -> Result<(), anyhow::Error> {
746 let Some(mut conn) = connect().await? else {
747 return Ok(());
748 };
749 const DB: &str = "mz_probe_binary_test";
750 recreate_db(&mut conn, DB).await?;
751 #[allow(clippy::disallowed_methods)]
756 conn.query_drop(format!(
757 "CREATE TABLE {DB}.t (id VARBINARY(36) PRIMARY KEY NOT NULL)"
758 ))
759 .await?;
760 let keys: Vec<Vec<u8>> = vec![b"a1".to_vec(), b"a2".to_vec(), vec![0xff, 0xfe, 0x31]];
761 conn.exec_batch(
762 format!("INSERT INTO {DB}.t VALUES (?)"),
763 keys.iter().map(|k| (Value::Bytes(k.clone()),)),
764 )
765 .await?;
766 #[allow(clippy::disallowed_methods)]
767 conn.query_drop(format!("ANALYZE TABLE {DB}.t")).await?;
768 let table = QualifiedTableRef {
769 schema_name: DB,
770 table_name: "t",
771 };
772 let mut tx = start_tx(&mut conn).await?;
773 let mut p = KeyProber::new(&mut tx, table, "id");
774
775 assert!(p.estimate_range_rows("", None).await.is_ok());
777
778 assert_eq!(
780 prefix_of_first_key_in_range(&mut p, "", None, 2).await,
781 some("a1")
782 );
783 assert_eq!(
784 prefix_of_first_row_not_matching_prefix(&mut p, "a1", None, 2).await,
785 some("a2")
786 );
787 let err = p
790 .prefix_of_first_row_not_matching_prefix("a2", None, 2)
791 .await
792 .unwrap_err();
793 assert!(matches!(err, MySqlError::NonUtf8KeyValue { .. }), "{err:?}");
794
795 tx.rollback().await?;
796 drop_db(&mut conn, DB).await?;
797 conn.disconnect().await?;
798 Ok(())
799 }
800
801 #[mz_ore::test(tokio::test)]
802 #[cfg_attr(miri, ignore)]
803 async fn test_live_mysql_stale_statistics() -> Result<(), anyhow::Error> {
804 let Some(mut conn) = connect().await? else {
805 return Ok(());
806 };
807 const DB: &str = "mz_probe_stale_test";
808 recreate_db(&mut conn, DB).await?;
809 #[allow(clippy::disallowed_methods)]
813 {
814 conn.query_drop(format!(
815 "CREATE TABLE {DB}.t (id VARCHAR(36) CHARACTER SET utf8mb4 \
816 COLLATE utf8mb4_bin PRIMARY KEY NOT NULL) \
817 STATS_AUTO_RECALC=0, STATS_PERSISTENT=1"
818 ))
819 .await?;
820 conn.query_drop(format!("ANALYZE TABLE {DB}.t")).await?;
821 }
822 let ids: Vec<String> = (0..1000).map(|i| format!("a{i:05}")).collect();
823 conn.exec_batch(
824 format!("INSERT INTO {DB}.t VALUES (?)"),
825 ids.iter().map(|id| (id.as_str(),)),
826 )
827 .await?;
828
829 let table_rows: Option<u64> = conn
831 .exec_first(
832 "SELECT table_rows FROM information_schema.tables \
833 WHERE table_schema = ? AND table_name = 't'",
834 (DB,),
835 )
836 .await?;
837 assert_eq!(table_rows, Some(0));
838
839 let table = QualifiedTableRef {
840 schema_name: DB,
841 table_name: "t",
842 };
843 let mut tx = start_tx(&mut conn).await?;
844 let mut prober = KeyProber::new(&mut tx, table, "id");
845
846 let all = prober.estimate_range_rows("", None).await?;
849 assert!((500..=2000).contains(&all), "all={all}");
850 let range = prober.estimate_range_rows("a00100", Some("a00200")).await?;
851 assert!((50..=200).contains(&range), "range={range}");
852
853 tx.rollback().await?;
854 drop_db(&mut conn, DB).await?;
855 conn.disconnect().await?;
856 Ok(())
857 }
858
859 #[mz_ore::test(tokio::test)]
860 #[cfg_attr(miri, ignore)]
861 async fn test_probe_sargability() -> Result<(), anyhow::Error> {
862 let Some(mut conn) = connect().await? else {
863 return Ok(());
864 };
865 const DB: &str = "mz_probe_sargable_test";
866 let ids: Vec<String> = (0..1000).map(|i| format!("a{i:05}")).collect();
867 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &ids).await?;
868
869 let mut tx = start_tx(&mut conn).await?;
870
871 let before = handler_reads(&mut tx).await?;
874 let _: Option<u64> = tx
875 .exec_first(
876 format!("SELECT COUNT(*) FROM {DB}.t WHERE LEFT(id, 2) = 'a0'"),
877 (),
878 )
879 .await?;
880 let scan_reads = handler_reads(&mut tx).await? - before;
881 assert!(scan_reads >= 1000, "scan_reads={scan_reads}");
882
883 let before = handler_reads(&mut tx).await?;
886 let got = prefix_of_first_key_in_range(
887 &mut KeyProber::new(&mut tx, table.clone(), "id"),
888 "a00500",
889 None,
890 6,
891 )
892 .await;
893 let reads = handler_reads(&mut tx).await? - before;
894 assert_eq!(got, some("a00501"));
896 assert!(reads < 50, "prefix_of_first_key_in_range reads={reads}");
897
898 let before = handler_reads(&mut tx).await?;
899 let got = prefix_of_first_row_not_matching_prefix(
900 &mut KeyProber::new(&mut tx, table.clone(), "id"),
901 "a00500",
902 None,
903 6,
904 )
905 .await;
906 let reads = handler_reads(&mut tx).await? - before;
907 assert_eq!(got, some("a00501"));
908 assert!(reads < 50, "max_key probe reads={reads}");
909
910 let before = handler_reads(&mut tx).await?;
911 let got = prefix_of_first_row_not_matching_prefix(
912 &mut KeyProber::new(&mut tx, table.clone(), "id"),
913 "a0",
914 None,
915 6,
916 )
917 .await;
918 let reads = handler_reads(&mut tx).await? - before;
919 assert_eq!(got, None);
923 assert!(reads < 50, "whole-table match reads={reads}");
924
925 let before = handler_reads(&mut tx).await?;
926 let got = prefix_of_first_key_in_range(
927 &mut KeyProber::new(&mut tx, table.clone(), "id"),
928 "a00500",
929 Some("a00501"),
930 6,
931 )
932 .await;
933 let reads = handler_reads(&mut tx).await? - before;
934 assert_eq!(got, None);
935 assert!(reads < 50, "empty bounded range reads={reads}");
936
937 let bounded = KeyProber::new(&mut tx, table.clone(), "id")
938 .estimate_range_rows("a00100", Some("a00200"))
939 .await?;
940 assert!((50..=300).contains(&bounded), "bounded estimate={bounded}");
941
942 tx.rollback().await?;
943 drop_db(&mut conn, DB).await?;
944 conn.disconnect().await?;
945 Ok(())
946 }
947
948 #[mz_ore::test(tokio::test)]
952 #[cfg_attr(miri, ignore)]
953 async fn test_live_mysql_bin_no_contraction_or_expansion() -> Result<(), anyhow::Error> {
954 let Some(mut conn) = connect().await? else {
955 return Ok(());
956 };
957 const DB: &str = "mz_probe_bin_no_hazards";
958 let keys = [
959 "aaa", "asz", "aßx", "cesta", "chleba", "duha", "hora", "ibis",
960 ];
961 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &keys).await?;
962
963 let mut tx = start_tx(&mut conn).await?;
964 let p = &mut KeyProber::new(&mut tx, table, "id");
965 assert_eq!(walk_prefixes(p, 1).await?, ["a", "c", "d", "h", "i"]);
966 assert_eq!(
967 walk_prefixes(p, 2).await?,
968 ["aa", "as", "aß", "ce", "ch", "du", "ho", "ib"]
969 );
970
971 tx.rollback().await?;
972 drop_db(&mut conn, DB).await?;
973 conn.disconnect().await?;
974 Ok(())
975 }
976
977 #[mz_ore::test(tokio::test)]
984 #[cfg_attr(miri, ignore)]
985 async fn test_live_mysql_keys_below_empty_string() -> Result<(), anyhow::Error> {
986 let Some(mut conn) = connect().await? else {
987 return Ok(());
988 };
989 const DB: &str = "mz_probe_below_empty_test";
990 let keys = ["\0a", "\u{1}a", "\u{9}b", "a1", "a1\u{1}x", "b1"];
991 let table = setup_table(&mut conn, DB, "utf8mb4_bin", &keys).await?;
992
993 let mut tx = start_tx(&mut conn).await?;
994 let p = &mut KeyProber::new(&mut tx, table, "id");
995 assert_eq!(
996 prefix_of_first_key_in_range(p, "", None, 2).await,
997 some("a1")
998 );
999 assert_eq!(
1000 prefix_of_first_row_not_matching_prefix(p, "a1", None, 2).await,
1001 some("b1")
1002 );
1003 assert_eq!(
1004 prefix_of_first_row_not_matching_prefix(p, "b1", None, 2).await,
1005 None
1006 );
1007
1008 assert_eq!(
1009 prefix_of_first_key_in_range(p, "", Some("a1"), 2).await,
1010 None
1011 );
1012 assert_eq!(
1013 prefix_of_first_row_not_matching_prefix(p, "\u{9}", Some("a1"), 2).await,
1014 None
1015 );
1016 assert_eq!(
1017 prefix_of_first_key_in_range(p, "", Some("b1"), 2).await,
1018 some("a1")
1019 );
1020
1021 tx.rollback().await?;
1022 drop_db(&mut conn, DB).await?;
1023 conn.disconnect().await?;
1024 Ok(())
1025 }
1026
1027 pub(crate) async fn connect() -> Result<Option<mysql_async::Conn>, anyhow::Error> {
1033 let Ok(url) = std::env::var("MZ_TEST_MYSQL_URL") else {
1034 if mz_ore::env::is_var_truthy("CI") {
1035 panic!("CI is supposed to run this test but something has gone wrong!");
1036 }
1037 tracing::info!("MZ_TEST_MYSQL_URL not set: skipping live MySQL test");
1038 return Ok(None);
1039 };
1040 Ok(Some(
1041 mysql_async::Conn::new(mysql_async::Opts::from_url(&url)?).await?,
1042 ))
1043 }
1044
1045 pub(crate) async fn start_tx(
1048 conn: &mut mysql_async::Conn,
1049 ) -> Result<Transaction<'_>, anyhow::Error> {
1050 let mut tx_opts = mysql_async::TxOpts::default();
1051 tx_opts
1052 .with_isolation_level(mysql_async::IsolationLevel::RepeatableRead)
1053 .with_readonly(true);
1054 Ok(conn.start_transaction(tx_opts).await?)
1055 }
1056
1057 async fn recreate_db(conn: &mut mysql_async::Conn, db: &str) -> Result<(), anyhow::Error> {
1060 #[allow(clippy::disallowed_methods)]
1061 {
1062 conn.query_drop(format!("DROP DATABASE IF EXISTS {db}"))
1063 .await?;
1064 conn.query_drop(format!("CREATE DATABASE {db}")).await?;
1065 }
1066 Ok(())
1067 }
1068
1069 pub(crate) async fn setup_table<'a>(
1073 conn: &mut mysql_async::Conn,
1074 db: &'a str,
1075 collation: &str,
1076 keys: &[impl AsRef<str> + Sync],
1077 ) -> Result<QualifiedTableRef<'a>, anyhow::Error> {
1078 recreate_db(conn, db).await?;
1079 let charset = collation.split('_').next().expect("nonempty collation");
1082 #[allow(clippy::disallowed_methods)]
1083 conn.query_drop(format!(
1084 "CREATE TABLE {db}.t (id VARCHAR(36) CHARACTER SET {charset} \
1085 COLLATE {collation} PRIMARY KEY NOT NULL)"
1086 ))
1087 .await?;
1088
1089 for chunk in keys.chunks(1000) {
1090 conn.exec_drop(
1091 format!(
1092 "INSERT INTO {db}.t VALUES {}",
1093 vec!["(?)"; chunk.len()].join(",")
1094 ),
1095 chunk
1096 .iter()
1097 .map(|id| id.as_ref().into())
1098 .collect::<Vec<mysql_async::Value>>(),
1099 )
1100 .await?;
1101 }
1102 #[allow(clippy::disallowed_methods)]
1103 conn.query_drop(format!("ANALYZE TABLE {db}.t")).await?;
1104 Ok(QualifiedTableRef {
1105 schema_name: db,
1106 table_name: "t",
1107 })
1108 }
1109
1110 pub(crate) async fn drop_db(
1112 conn: &mut mysql_async::Conn,
1113 db: &str,
1114 ) -> Result<(), anyhow::Error> {
1115 #[allow(clippy::disallowed_methods)]
1116 conn.query_drop(format!("DROP DATABASE {db}")).await?;
1117 Ok(())
1118 }
1119
1120 async fn count_range(
1124 conn: &mut mysql_async::Conn,
1125 db: &str,
1126 lo: &str,
1127 hi: Option<&String>,
1128 ) -> Result<(u64, u64), anyhow::Error> {
1129 let mut clause = "id >= ?".to_string();
1130 let mut params: Vec<Value> = vec![lo.into(), lo.into(), lo.into()];
1131 if let Some(hi) = hi {
1132 clause.push_str(" AND id < ?");
1133 params.push(hi.as_str().into());
1134 }
1135 let row: Option<(u64, Option<u64>)> = conn
1136 .exec_first(
1137 format!(
1138 "SELECT COUNT(*), SUM(LEFT(id, CHAR_LENGTH(?)) = ?) FROM {db}.t WHERE {clause}"
1139 ),
1140 Params::Positional(params),
1141 )
1142 .await?;
1143 let (total, prefixed) = row.expect("COUNT returns a row");
1144 Ok((total, prefixed.unwrap_or(0)))
1145 }
1146
1147 async fn handler_reads(tx: &mut Transaction<'_>) -> Result<u64, anyhow::Error> {
1150 let rows: Vec<(String, String)> = tx
1151 .exec("SHOW SESSION STATUS LIKE 'Handler_read%'", ())
1152 .await?;
1153 Ok(rows.into_iter().map(|(_, v)| v.parse().unwrap_or(0)).sum())
1154 }
1155
1156 async fn prefix_of_first_key_in_range(
1158 prober: &mut KeyProber<'_, '_>,
1159 lower_bound_exclusive: &str,
1160 upper_bound_exclusive: Option<&str>,
1161 max_prefix_length: usize,
1162 ) -> Option<String> {
1163 prober
1164 .prefix_of_first_key_in_range(
1165 lower_bound_exclusive,
1166 upper_bound_exclusive,
1167 max_prefix_length,
1168 )
1169 .await
1170 .expect("prefix_of_first_key_in_range failed")
1171 }
1172
1173 async fn prefix_of_first_row_not_matching_prefix(
1175 prober: &mut KeyProber<'_, '_>,
1176 prefix: &str,
1177 upper_bound_exclusive: Option<&str>,
1178 max_prefix_length: usize,
1179 ) -> Option<String> {
1180 prober
1181 .prefix_of_first_row_not_matching_prefix(
1182 prefix,
1183 upper_bound_exclusive,
1184 max_prefix_length,
1185 )
1186 .await
1187 .expect("prefix_of_first_row_not_matching_prefix failed")
1188 }
1189
1190 fn some(s: &str) -> Option<String> {
1194 Some(s.into())
1195 }
1196
1197 async fn walk_prefixes(
1200 prober: &mut KeyProber<'_, '_>,
1201 len: usize,
1202 ) -> Result<Vec<String>, anyhow::Error> {
1203 let mut walked = Vec::new();
1204 let Some(mut cur) = prober.prefix_of_first_key_in_range("", None, len).await? else {
1205 return Ok(walked);
1206 };
1207 loop {
1208 assert!(
1209 !walked.contains(&cur),
1210 "prefix repeated: {cur:?} (walked: {walked:?})"
1211 );
1212 walked.push(cur.clone());
1213 match prober
1214 .prefix_of_first_row_not_matching_prefix(&cur, None, len)
1215 .await?
1216 {
1217 Some(next) => cur = next,
1218 None => break,
1219 }
1220 }
1221 Ok(walked)
1222 }
1223}