6262//!
6363//! ## Parallel PK-range snapshots
6464//!
65- //! For tables with a suitable primary key, the leader computes `worker_count - 1` boundary keys
66- //! that split the key domain into disjoint half-open ranges, and broadcasts them. Each worker
65+ //! For tables with a suitable single-column string primary key, the leader computes up to
66+ //! `worker_count - 1` boundary keys that split the key domain into disjoint half-open ranges,
67+ //! and broadcasts them. Boundaries are discovered by splitting the key space on character
68+ //! prefixes using optimizer row estimates (see [`mz_mysql_util::partition`]). Each worker
6769//! reads only its assigned range. Ranges are assigned round-robin starting from each table's
6870//! legacy single-worker owner, so the open-ended ranges (which absorb any rows written past the
6971//! last sampled boundary) land on a different worker per table rather than always the last worker.
@@ -116,7 +118,9 @@ use futures::{StreamExt as _, TryStreamExt};
116118use itertools:: Itertools ;
117119use mysql_async:: prelude:: Queryable ;
118120use mysql_async:: { IsolationLevel , Row as MySqlRow , TxOpts } ;
119- use mz_mysql_util:: { MySqlConn , MySqlError , pack_mysql_row, query_sys_var, quote_identifier} ;
121+ use mz_mysql_util:: {
122+ MySqlConn , MySqlError , QualifiedTableRef , pack_mysql_row, query_sys_var, quote_identifier,
123+ } ;
120124use mz_ore:: cast:: CastFrom ;
121125use mz_ore:: future:: InTask ;
122126use mz_ore:: iter:: IteratorExt ;
@@ -220,92 +224,99 @@ fn worker_pk_range(
220224 } )
221225}
222226
223- /// Walks the primary key index in steps of about `row_count / worker_count`, taking the key
224- /// at each step's `OFFSET`. The per-step OFFSET scans sum to a full index pass, so this
225- /// function has a time complexity of O(row_count). Worker count is small, so the OFFSET
226- /// scans dominate the runtime. `row_count` can be an optimizer estimate for large tables,
227- /// so the partitions are approximate. An overestimate walks off the end of the index and stops
228- /// with fewer boundaries, resulting in some workers receiving less or no work. An underestimate
229- /// leaves a larger final partition for the last worker, however both still correctly partition
230- /// the table. Returns None if the primary key column type is not supported or the table is too
231- /// small to split.
232- async fn compute_sampled_splits < Q > (
233- conn : & mut Q ,
227+ /// Computes PK-range split boundaries for `table` by partitioning the key
228+ /// space by character prefix (see [`mz_mysql_util::partition`]) and rendering
229+ /// the resulting boundaries as SQL string literals via the server's `QUOTE()`,
230+ /// matching the literal interpolation the range predicates use. Only string
231+ /// key columns can be split this way, prefixes of other types do not order
232+ /// consistently with their values. Returns None if the primary key column type
233+ /// is not supported or the table is not worth splitting.
234+ async fn compute_pk_splits (
235+ conn : & mut mysql_async:: Conn ,
234236 table : & MySqlTableName ,
235- pk_col : & ( String , SqlScalarType ) ,
237+ raw_col : & str ,
238+ scalar_type : & SqlScalarType ,
236239 worker_count : usize ,
237- total : u64 ,
238- ) -> Result < Option < PkBoundaries > , TransientError >
239- where
240- Q : Queryable ,
241- {
242- let ( col, scalar_type) = pk_col;
243- // Render the PK column as text that sorts and compares the same way the range
244- // predicates do: `QUOTE()` under the column's collation for character types,
245- // `CAST(.. AS CHAR)` for integers. Any other type can't be split safely.
246- let ( col_literal, integer_path) = match scalar_type {
247- SqlScalarType :: Int16
248- | SqlScalarType :: Int32
249- | SqlScalarType :: Int64
250- | SqlScalarType :: UInt16
251- | SqlScalarType :: UInt32
252- | SqlScalarType :: UInt64 => ( format ! ( "CAST({col} AS CHAR)" ) , true ) ,
253- SqlScalarType :: Char { .. } | SqlScalarType :: VarChar { .. } | SqlScalarType :: String => {
254- ( format ! ( "QUOTE({col})" ) , false )
255- }
240+ row_count : u64 ,
241+ partition_min_rows : u64 ,
242+ partition_probed_prefixes_per_billion_rows : u64 ,
243+ ) -> Result < Option < PkBoundaries > , TransientError > {
244+ match scalar_type {
245+ SqlScalarType :: Char { .. } | SqlScalarType :: VarChar { .. } | SqlScalarType :: String => { }
256246 _ => return Ok ( None ) ,
257- } ;
258-
259- let partitions = std:: cmp:: min ( u64:: cast_from ( worker_count) , total) ;
260- if partitions < 2 {
247+ }
248+ // Contractions (Czech ch), expansions (ß), ignorable characters (NUL),
249+ // and NO PAD ordering all break prefix probing, so only split under
250+ // utf8mb4_bin, the one collation it is verified against.
251+ let collation: Option < String > = conn
252+ . exec_first (
253+ "SELECT COLLATION_NAME FROM information_schema.columns \
254+ WHERE table_schema = ? AND table_name = ? AND column_name = ?",
255+ ( & table. 0 , & table. 1 , raw_col) ,
256+ )
257+ . await ?;
258+ let supported = collation. as_deref ( ) . is_some_and ( |c| c == "utf8mb4_bin" ) ;
259+ if !supported {
260+ tracing:: debug!( ?collation, "PK splitting skipped: unsupported collation" ) ;
261261 return Ok ( None ) ;
262262 }
263- let chunk = total / partitions;
264-
265- let mut boundaries: Vec < String > = Vec :: with_capacity ( usize:: cast_from ( partitions) - 1 ) ;
266- for _ in 1 ..partitions {
267- let ( predicate, offset) = match boundaries. last ( ) {
268- Some ( prev) => ( format ! ( " WHERE {col} > {prev}" ) , chunk - 1 ) ,
269- None => ( String :: new ( ) , chunk) ,
270- } ;
271- // The identifier is quoted via `quote_identifier`, the previous boundary is
272- // itself a value MySQL rendered as a literal, `table` via Display, and the
273- // offset is an integer, so this interpolation is safe; not parameterizable.
274- #[ allow( clippy:: disallowed_methods) ]
275- let row: Option < MySqlRow > = conn
276- . query_first ( format ! (
277- "SELECT {col_literal} FROM {table}{predicate} \
278- ORDER BY {col} LIMIT 1 OFFSET {offset}"
279- ) )
280- . await ?;
281- // Defensive: if a concurrent write shrank the range out from under us, stop and
282- // use the boundaries found so far. Fewer partitions is still correct.
283- let Some ( mut row) = row else { break } ;
284- // The column is CAST/QUOTE-ed to text, so it decodes as a String that is
285- // already a valid SQL literal. A decode failure (e.g. a non-UTF-8
286- // collation) means we can't safely partition: fall back.
287- match row. take_opt :: < String , usize > ( 0 ) {
288- Some ( Ok ( lit) ) if !integer_path || is_decimal_literal ( & lit) => boundaries. push ( lit) ,
289- _ => return Ok ( None ) ,
263+ let table_ref = QualifiedTableRef {
264+ schema_name : & table. 0 ,
265+ table_name : & table. 1 ,
266+ } ;
267+ // Probe budget proportional to the estimated snapshot work, so probing
268+ // effort stays negligible next to reading the table. The floor keeps
269+ // small tables able to afford their handful of splits.
270+ let max_probed_prefixes =
271+ ( row_count. saturating_mul ( partition_probed_prefixes_per_billion_rows) / 1_000_000_000 )
272+ . max ( 256 ) ;
273+ let prefixes = match mz_mysql_util:: partition_table (
274+ conn,
275+ table_ref,
276+ raw_col,
277+ worker_count,
278+ row_count,
279+ partition_min_rows,
280+ max_probed_prefixes,
281+ )
282+ . await
283+ {
284+ Ok ( prefixes) => prefixes,
285+ // Correctness never depends on splitting, so unsupported key data or
286+ // an optimizer that reports no row estimate falls back to the
287+ // single-worker whole-table read instead of failing the snapshot.
288+ Err ( err @ ( MySqlError :: NonUtf8KeyValue { .. } | MySqlError :: MissingRowEstimate { .. } ) ) => {
289+ tracing:: warn!( %err, "PK splitting fell back to a single partition" ) ;
290+ return Ok ( None ) ;
290291 }
291- }
292- if boundaries. is_empty ( ) {
292+ Err ( err) => return Err ( err. into ( ) ) ,
293+ } ;
294+ if prefixes. is_empty ( ) {
293295 return Ok ( None ) ;
294296 }
297+ let mut boundaries = Vec :: with_capacity ( prefixes. len ( ) ) ;
298+ for prefix in prefixes {
299+ let literal: Option < String > = conn. exec_first ( "SELECT QUOTE(?)" , ( prefix, ) ) . await ?;
300+ // QUOTE of a non-NULL parameter always returns a row, but fall back
301+ // rather than panic if the protocol surprises us.
302+ let Some ( literal) = literal else {
303+ return Ok ( None ) ;
304+ } ;
305+ boundaries. push ( literal) ;
306+ }
295307 Ok ( Some ( PkBoundaries {
296- pk_col : col . clone ( ) ,
308+ pk_col : quote_identifier ( raw_col ) ,
297309 boundaries,
298310 } ) )
299311}
300312
301313/// For every table, read the row count (exact only for small tables) and, for a
302314/// supported single-column primary key, compute the PK-range split boundaries,
303315/// concurrently over at most `worker_count` connections. `None` bounds means
304- /// single-worker fallback for that table. The counts are reused for both the sampling
305- /// stride and the snapshot size gauge. Snapshot size gauge is a metric for the snapshot
306- /// size used to report how many rows we need to process. "Sampling stride" refers to
307- /// the number of rows we use to page through the table to find roughly evenly spaced
308- /// primary keys to use as partition boundaries.
316+ /// single-worker fallback for that table. The counts are reused for both boundary
317+ /// discovery and the snapshot size gauge. The snapshot size gauge is a metric
318+ /// reporting how many rows the snapshot needs to process. Boundary discovery uses
319+ /// the count to size the partitioner's target buckets.
309320async fn sample_pk_bounds (
310321 config : & RawSourceCreationConfig ,
311322 connection_config : & mz_mysql_util:: Config ,
@@ -335,6 +346,14 @@ async fn sample_pk_bounds(
335346 mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS
336347 . get ( config. config . config_set ( ) ) ,
337348 ) ;
349+ let partition_min_rows = u64:: cast_from (
350+ mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS
351+ . get ( config. config . config_set ( ) ) ,
352+ ) ;
353+ let partition_probed_prefixes_per_billion_rows = u64:: cast_from (
354+ mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_PARTITION_PROBED_PREFIXES_PER_BILLION_ROWS
355+ . get ( config. config . config_set ( ) ) ,
356+ ) ;
338357
339358 let pooled_conns: Rc < RefCell < Vec < MySqlConn > > > = Rc :: new ( RefCell :: new ( Vec :: new ( ) ) ) ;
340359 // Counting and boundary-sampling each walk a table's index (O(rows)), so run tables
@@ -366,11 +385,11 @@ async fn sample_pk_bounds(
366385 conn
367386 }
368387 } ;
369- // Row count, reused for the sampling stride and the size gauge. When it
388+ // Row count, reused for boundary discovery and the size gauge. When it
370389 // is counted exactly it runs on the same `READ ONLY` transaction as the
371- // boundary walk in `compute_sampled_splits `, so both see one consistent
390+ // boundary probes in `compute_pk_splits `, so both see one consistent
372391 // snapshot. For large tables it is an optimizer estimate instead, which
373- // `compute_sampled_splits ` tolerates.
392+ // `compute_pk_splits ` tolerates.
374393 let stats =
375394 collect_table_statistics ( & mut * conn, table, exact_count_max_rows) . await ?;
376395 metrics. record_table_count_latency (
@@ -385,9 +404,17 @@ async fn sample_pk_bounds(
385404 . flatten ( )
386405 {
387406 Some ( ( raw_col, scalar_type) ) => {
388- let pk_col = ( quote_identifier ( & raw_col) , scalar_type) ;
389- compute_sampled_splits ( & mut * conn, table, & pk_col, worker_count, count)
390- . await ?
407+ compute_pk_splits (
408+ & mut * conn,
409+ table,
410+ & raw_col,
411+ & scalar_type,
412+ worker_count,
413+ count,
414+ partition_min_rows,
415+ partition_probed_prefixes_per_billion_rows,
416+ )
417+ . await ?
391418 }
392419 None => None ,
393420 } ;
@@ -619,11 +646,6 @@ fn is_plain_ident(s: &str) -> bool {
619646 !s. is_empty ( ) && s. chars ( ) . all ( |c| c. is_ascii_alphanumeric ( ) || c == '_' )
620647}
621648
622- fn is_decimal_literal ( s : & str ) -> bool {
623- let digits = s. strip_prefix ( '-' ) . unwrap_or ( s) ;
624- !digits. is_empty ( ) && digits. bytes ( ) . all ( |b| b. is_ascii_digit ( ) )
625- }
626-
627649/// Returns the set of full tables/sections of tables to read.
628650fn plan_worker_reads (
629651 config : & RawSourceCreationConfig ,
0 commit comments