Skip to content

Commit 4f445e1

Browse files
storage: partition MySQL string PK snapshots by key prefix
Replace the OFFSET-walking boundary discovery with the prefix-based partitioner in mz-mysql-util, so discovery costs EXPLAIN index dives instead of an O(rows) index pass. Only string primary keys are supported. Integer keys, which the OFFSET walk used to sample, now fall back to a single-worker whole-table read. Prefixes of a numeric key do not order consistently with its values, so they would need a separate numeric range splitter. Boundaries are rendered as SQL literals via the server QUOTE() and still pass the existing strict-monotonicity verification in each read transaction. The new mysql_source_snapshot_partition_min_rows dyncfg (default 50000) stops splitting below a minimum range size. Test configs set it low so the tiny tables in mysql-cdc testdrive and parallel-workload still exercise range reads.
1 parent 48cfbb4 commit 4f445e1

5 files changed

Lines changed: 169 additions & 106 deletions

File tree

misc/python/materialize/mzcompose/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ def get_variable_system_parameters(
298298
VariableSystemParameter(
299299
"mysql_source_snapshot_parallelism", "true", ["true", "false"]
300300
),
301+
# Low default so the tiny tables in tests still exercise PK-prefix
302+
# range splitting; the production default only splits large tables.
303+
VariableSystemParameter(
304+
"mysql_source_snapshot_partition_min_rows", "2", ["2", "50000"]
305+
),
301306
VariableSystemParameter(
302307
"persist_batch_columnar_format",
303308
"structured" if version > MzVersion.parse_mz("v0.135.0-dev") else "both_v2",
@@ -701,6 +706,9 @@ def get_default_system_parameters(
701706
# The estimated path is covered explicitly in mysql-cdc/statistics.td and
702707
# by parallel-workload.
703708
"mysql_source_snapshot_exact_count_max_rows",
709+
# Not varied here because the 256-prefix floor dominates for test-sized
710+
# tables. parallel-workload flips it.
711+
"mysql_source_snapshot_partition_probed_prefixes_per_billion_rows",
704712
"postgres_fetch_slot_resume_lsn_interval",
705713
"pg_schema_validation_interval",
706714
"pg_source_validate_timeline",

misc/python/materialize/parallel_workload/action.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3046,6 +3046,20 @@ def __init__(
30463046
self.flags_with_values["mysql_source_snapshot_parallelism"] = (
30473047
BOOLEAN_FLAG_VALUES
30483048
)
3049+
# 2 exercises PK-prefix splitting on workload-sized tables, the
3050+
# default leaves them in a single bucket.
3051+
self.flags_with_values["mysql_source_snapshot_partition_min_rows"] = [
3052+
"2",
3053+
"50000",
3054+
]
3055+
# 0 leaves only the 256-prefix floor, the default scales with table
3056+
# size.
3057+
self.flags_with_values[
3058+
"mysql_source_snapshot_partition_probed_prefixes_per_billion_rows"
3059+
] = [
3060+
"0",
3061+
"1000",
3062+
]
30493063

30503064
# If you are adding a new config flag in Materialize, consider using it
30513065
# here instead of just marking it as uninteresting to silence the

src/storage-types/src/dyncfgs.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,30 @@ pub static MYSQL_SOURCE_SNAPSHOT_PARALLELISM: Config<bool> = Config::new(
215215
"Whether to split MySQL snapshot reads across workers by primary-key ranges.",
216216
);
217217

218+
/// The smallest estimated row count for which the MySQL snapshot prefix
219+
/// partitioner keeps splitting a string primary key range. Tables estimated
220+
/// below this stay in a single per-table bucket, i.e. are read by one worker.
221+
pub static MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS: Config<usize> = Config::new(
222+
"mysql_source_snapshot_partition_min_rows",
223+
50_000,
224+
"Minimum estimated rows per range before MySQL snapshot PK-prefix partitioning \
225+
stops splitting; also the smallest table considered worth splitting.",
226+
);
227+
228+
/// Probed-prefix budget for the MySQL snapshot prefix partitioner, scaled
229+
/// to the table's estimated size so probing effort stays proportional to the
230+
/// snapshot work it optimizes. Each probed prefix costs a couple of queries.
231+
/// A small floor applies so modest tables can still afford their handful of
232+
/// splits. When a table's budget runs out, splitting stops early and buckets
233+
/// come out coarser, never incorrect.
234+
pub static MYSQL_SOURCE_SNAPSHOT_PARTITION_PROBED_PREFIXES_PER_BILLION_ROWS: Config<usize> =
235+
Config::new(
236+
"mysql_source_snapshot_partition_probed_prefixes_per_billion_rows",
237+
1_000,
238+
"Cap on MySQL snapshot PK-prefix partitioning probed prefixes per table, per billion \
239+
estimated rows; when exhausted, splitting stops early with coarser buckets.",
240+
);
241+
218242
/// If the optimizer estimates the table has fewer rows than this, compute the exact row count
219243
/// with `COUNT(*)`. Otherwise, report the `information_schema` estimate directly.
220244
pub static MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS: Config<usize> = Config::new(
@@ -438,6 +462,8 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
438462
.add(&MYSQL_REPLICATION_HEARTBEAT_INTERVAL)
439463
.add(&MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS)
440464
.add(&MYSQL_SOURCE_SNAPSHOT_PARALLELISM)
465+
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS)
466+
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_PROBED_PREFIXES_PER_BILLION_ROWS)
441467
.add(&ORE_OVERFLOWING_BEHAVIOR)
442468
.add(&PG_FETCH_SLOT_RESUME_LSN_INTERVAL)
443469
.add(&PG_SCHEMA_VALIDATION_INTERVAL)

src/storage/src/source/mysql/snapshot.rs

Lines changed: 106 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@
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};
116118
use itertools::Itertools;
117119
use mysql_async::prelude::Queryable;
118120
use 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+
};
120124
use mz_ore::cast::CastFrom;
121125
use mz_ore::future::InTask;
122126
use 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.
309320
async 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.
628650
fn plan_worker_reads(
629651
config: &RawSourceCreationConfig,

0 commit comments

Comments
 (0)