Skip to content

Commit 7ddc4f3

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 90dc1b3 commit 7ddc4f3

5 files changed

Lines changed: 142 additions & 106 deletions

File tree

‎misc/python/materialize/mzcompose/__init__.py‎

Lines changed: 5 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",

‎misc/python/materialize/parallel_workload/action.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3046,6 +3046,12 @@ 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+
]
30493055
# 0 leaves only the 256-request floor, the default scales with table
30503056
# size.
30513057
self.flags_with_values[

‎src/storage-types/src/dyncfgs.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,16 @@ 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+
218228
/// Probe query budget for the MySQL snapshot prefix partitioner, scaled to
219229
/// the table's estimated size so probing effort stays proportional to the
220230
/// snapshot work it optimizes. A small floor applies so modest tables can
@@ -450,6 +460,7 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
450460
.add(&MYSQL_REPLICATION_HEARTBEAT_INTERVAL)
451461
.add(&MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS)
452462
.add(&MYSQL_SOURCE_SNAPSHOT_PARALLELISM)
463+
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS)
453464
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_REQUESTS_PER_BILLION_ROWS)
454465
.add(&ORE_OVERFLOWING_BEHAVIOR)
455466
.add(&PG_FETCH_SLOT_RESUME_LSN_INTERVAL)

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

Lines changed: 105 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,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.
309319
async 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.
628649
fn plan_worker_reads(
629650
config: &RawSourceCreationConfig,

‎test/mysql-cdc/mysql-cdc.td‎

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -769,16 +769,12 @@ $ mysql-execute name=mysql
769769
DROP TABLE pk_uint_test;
770770

771771
#
772-
# BIT primary key. A BIT column maps to a Materialize uint64, but carries the
773-
# MySqlColumnMeta::Bit marker. The PK-range sampler renders a boundary from the
774-
# Materialize scalar type alone (uint64 takes the integer path, CAST(id AS CHAR)),
775-
# discarding that marker. For a BIT column CAST(.. AS CHAR) returns the value's
776-
# raw bytes, not a decimal literal, and that text is spliced verbatim into the
777-
# worker range predicates. The BIT(64) value X'30204F5220313D31' is the eight
778-
# ASCII bytes "0 OR 1=1", so the sampled boundary turns the two worker reads into
779-
# WHERE id < 0 OR 1=1
780-
# WHERE id >= 0 OR 1=1
781-
# both of which match every row. Every key must still be emitted exactly once.
772+
# BIT primary key. A BIT column maps to a Materialize uint64, so it is not a
773+
# string key and must not be split into PK ranges. The BIT(64) value
774+
# X'30204F5220313D31' is the eight ASCII bytes "0 OR 1=1": a splitter that
775+
# rendered the raw key bytes into a range predicate would turn a worker read
776+
# into WHERE id >= 0 OR 1=1, matching every row on every worker. Every key
777+
# must still be emitted exactly once.
782778
#
783779

784780
$ mysql-execute name=mysql
@@ -793,8 +789,9 @@ INSERT INTO pk_bit_test VALUES (1, 100), (0x30204F5220313D31, 200);
793789
FROM MYSQL CONNECTION mysql_conn;
794790
> CREATE TABLE pk_bit_table FROM SOURCE pk_bit_source (REFERENCE public.pk_bit_test);
795791

796-
# Exact count and no duplicate keys. Under the bug both workers read the whole
797-
# table, so COUNT(*) is 4 while COUNT(DISTINCT id) stays 2.
792+
# Exact count and no duplicate keys. If the table were wrongly split and both
793+
# workers read the whole table, COUNT(*) would be 4 while COUNT(DISTINCT id)
794+
# stays 2.
798795
> SELECT COUNT(*), COUNT(DISTINCT id) FROM pk_bit_table;
799796
2 2
800797

@@ -812,19 +809,19 @@ DROP TABLE pk_bit_test;
812809

813810
#
814811
# PK-range splitting disabled via the mysql_source_snapshot_parallelism dyncfg.
815-
# The same shape as the pk_range_test above, but every table must fall back to a
816-
# single-worker whole-table read and still produce a complete, duplicate-free
817-
# snapshot.
812+
# The same shape as the pk_char_test above, a string PK that would otherwise be
813+
# split, but every table must fall back to a single-worker whole-table read and
814+
# still produce a complete, duplicate-free snapshot.
818815
#
819816

820817
$ postgres-execute connection=mz_system
821818
ALTER SYSTEM SET mysql_source_snapshot_parallelism = false
822819

823820
$ mysql-execute name=mysql
824821
DROP TABLE IF EXISTS pk_serial_test;
825-
CREATE TABLE pk_serial_test (id BIGINT PRIMARY KEY, val BIGINT);
822+
CREATE TABLE pk_serial_test (id CHAR(26) PRIMARY KEY, val BIGINT);
826823
SET @i := 0;
827-
INSERT INTO pk_serial_test SELECT @i := @i + 1, @i * 7 FROM mysql.time_zone t1, mysql.time_zone t2 LIMIT 1000;
824+
INSERT INTO pk_serial_test SELECT LPAD(CONV(@i := @i + 1, 10, 36), 26, '0'), @i * 7 FROM mysql.time_zone t1, mysql.time_zone t2 LIMIT 1000;
828825

829826
> CREATE CLUSTER pk_serial_cluster SIZE 'scale=1,workers=4'
830827

@@ -837,11 +834,7 @@ INSERT INTO pk_serial_test SELECT @i := @i + 1, @i * 7 FROM mysql.time_zone t1,
837834
> SELECT COUNT(*), COUNT(DISTINCT id) FROM pk_serial_table;
838835
1000 1000
839836

840-
# Full key range present (ids 1..1000)
841-
> SELECT MIN(id), MAX(id) FROM pk_serial_table;
842-
1 1000
843-
844-
# Checksum: val = id * 7, so SUM(val) = 7 * SUM(1..1000) = 7 * 500500
837+
# Checksum: val = row number * 7, so SUM(val) = 7 * SUM(1..1000) = 7 * 500500
845838
> SELECT SUM(val) FROM pk_serial_table;
846839
3503500
847840

0 commit comments

Comments
 (0)