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,98 @@ 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_requests_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_requests =
271+ ( row_count . saturating_mul ( partition_requests_per_billion_rows ) / 1_000_000_000 ) . max ( 256 ) ;
272+ let prefixes = match mz_mysql_util :: partition_table (
273+ conn ,
274+ table_ref ,
275+ raw_col ,
276+ worker_count ,
277+ row_count ,
278+ partition_min_rows ,
279+ max_requests ,
280+ )
281+ . await
282+ {
283+ Ok ( prefixes ) => prefixes ,
284+ // Correctness never depends on splitting , so unsupported key data or
285+ // an optimizer that reports no row estimate falls back to the
286+ // single-worker whole-table read instead of failing the snapshot .
287+ Err ( err @ ( MySqlError :: NonUtf8KeyValue { .. } | MySqlError :: MissingRowEstimate { .. } ) ) => {
288+ tracing :: warn! ( %err , "PK splitting fell back to a single partition" ) ;
289+ return Ok ( None ) ;
290290 }
291- }
292- if boundaries. is_empty ( ) {
291+ Err ( err) => return Err ( err. into ( ) ) ,
292+ } ;
293+ if prefixes. is_empty ( ) {
293294 return Ok ( None ) ;
294295 }
296+ let mut boundaries = Vec :: with_capacity ( prefixes. len ( ) ) ;
297+ for prefix in prefixes {
298+ let literal: Option < String > = conn. exec_first ( "SELECT QUOTE(?)" , ( prefix, ) ) . await ?;
299+ // QUOTE of a non-NULL parameter always returns a row, but fall back
300+ // rather than panic if the protocol surprises us.
301+ let Some ( literal) = literal else {
302+ return Ok ( None ) ;
303+ } ;
304+ boundaries. push ( literal) ;
305+ }
295306 Ok ( Some ( PkBoundaries {
296- pk_col : col . clone ( ) ,
307+ pk_col : quote_identifier ( raw_col ) ,
297308 boundaries,
298309 } ) )
299310}
300311
301312/// For every table, read the row count (exact only for small tables) and, for a
302313/// supported single-column primary key, compute the PK-range split boundaries,
303314/// 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.
315+ /// single-worker fallback for that table. The counts are reused for both boundary
316+ /// discovery and the snapshot size gauge. The snapshot size gauge is a metric
317+ /// reporting how many rows the snapshot needs to process. Boundary discovery uses
318+ /// the count to size the partitioner's target buckets.
309319async fn sample_pk_bounds (
310320 config : & RawSourceCreationConfig ,
311321 connection_config : & mz_mysql_util:: Config ,
@@ -335,6 +345,14 @@ async fn sample_pk_bounds(
335345 mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS
336346 . get ( config. config . config_set ( ) ) ,
337347 ) ;
348+ let partition_min_rows = u64:: cast_from (
349+ mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS
350+ . get ( config. config . config_set ( ) ) ,
351+ ) ;
352+ let partition_requests_per_billion_rows = u64:: cast_from (
353+ mz_storage_types:: dyncfgs:: MYSQL_SOURCE_SNAPSHOT_PARTITION_REQUESTS_PER_BILLION_ROWS
354+ . get ( config. config . config_set ( ) ) ,
355+ ) ;
338356
339357 let pooled_conns: Rc < RefCell < Vec < MySqlConn > > > = Rc :: new ( RefCell :: new ( Vec :: new ( ) ) ) ;
340358 // Counting and boundary-sampling each walk a table's index (O(rows)), so run tables
@@ -366,11 +384,11 @@ async fn sample_pk_bounds(
366384 conn
367385 }
368386 } ;
369- // Row count, reused for the sampling stride and the size gauge. When it
387+ // Row count, reused for boundary discovery and the size gauge. When it
370388 // 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
389+ // boundary probes in `compute_pk_splits `, so both see one consistent
372390 // snapshot. For large tables it is an optimizer estimate instead, which
373- // `compute_sampled_splits ` tolerates.
391+ // `compute_pk_splits ` tolerates.
374392 let stats =
375393 collect_table_statistics ( & mut * conn, table, exact_count_max_rows) . await ?;
376394 metrics. record_table_count_latency (
@@ -385,9 +403,17 @@ async fn sample_pk_bounds(
385403 . flatten ( )
386404 {
387405 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 ?
406+ compute_pk_splits (
407+ & mut * conn,
408+ table,
409+ & raw_col,
410+ & scalar_type,
411+ worker_count,
412+ count,
413+ partition_min_rows,
414+ partition_requests_per_billion_rows,
415+ )
416+ . await ?
391417 }
392418 None => None ,
393419 } ;
@@ -619,11 +645,6 @@ fn is_plain_ident(s: &str) -> bool {
619645 !s. is_empty ( ) && s. chars ( ) . all ( |c| c. is_ascii_alphanumeric ( ) || c == '_' )
620646}
621647
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-
627648/// Returns the set of full tables/sections of tables to read.
628649fn plan_worker_reads (
629650 config : & RawSourceCreationConfig ,
0 commit comments