Skip to content

[SS-97] MySQL Parallel Snapshot#37642

Open
peterdukelarsen wants to merge 8 commits into
MaterializeInc:mainfrom
peterdukelarsen:pl/parallel-mysql
Open

[SS-97] MySQL Parallel Snapshot#37642
peterdukelarsen wants to merge 8 commits into
MaterializeInc:mainfrom
peterdukelarsen:pl/parallel-mysql

Conversation

@peterdukelarsen

@peterdukelarsen peterdukelarsen commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Motivation

Taking over implementation of #35569.

For customers with large tables in MySQL the initial snapshot can be painfully slow. We can improve performance by distributing the initial snapshot across workers.

Description

Previously we would:

  1. Partition ownership of tables across workers
  2. Each worker would take out a read lock on the tables it owned, grab the gtid, open a txn and then release the readlock to let us get the gtid of the transaction
  3. Copy the entirety of the tables it owned one at a time

The problem with this is when one table is huge we are as slow as the single worker copying the whole table.

Now the MySQL snapshot logic at a high level will be:

  1. Have a leader compute partitions for tables where possible that can be shared across workers (more on this later)
  2. The leader takes a read lock on all tables to snapshot, grabs the current GTID, then broadcasts the gtid and partitions
  3. All workers including the leader will connect to mysql and open transactions while the tables are locked, then drop their capability to indicate to the leader they've opened the snapshot
  4. The leader will drop the read locks at this point unblocking writes to the source tables
  5. All workers will perform snapshots of the tables or sections of tables they own

The leader will parallelize the partition computation using multithreading. The partition computation is much faster than actually reading the full snapshot, but it is slow and scales with the table size. The leader will step through the table along the primary key sort order using offsets of 1 / # workers to compute partitions of approximately the same size to use for partitioning the table.

Verificiation

  • Testdrive tests

Benchmark Performance

Some basic benchmarks: https://buildkite.com/materialize/nightly/builds/17371/list. First two show it hasn't gotten slower for many tables while the last demonstrates the case of a single large table being sped up. This shows a 2.7x speedup on a large table without a slowdown on many tables (although it's possible there could be a slowdown of ~10% on perfectly even distribution of tables due to the extra work of partitioning).


NAME                                   | TYPE            |      THIS       |      OTHER      |  UNIT  | THRESHOLD  |  Regression?  | 'THIS' is
--------------------------------------------------------------------------------------------------------------------------------------------
MySqlInitialLoadMultiWorkerSampled     | wallclock       |           2.834 |           3.389 |   s    |    10%     |      no       | better: 16.4% faster

MySqlInitialLoadMultiWorkerSampled     | memory_mz       |        1379.387 |        1106.102 |   MB   |    60%     |      no       | worse:  24.7% more

MySqlInitialLoadMultiWorkerSampled     | memory_clusterd |          86.762 |          64.301 |   MB   |    60%     |      no       | worse:  34.9% more


MySqlInitialLoadMultiWorkerSingleTable | wallclock       |           0.813 |           2.233 |   s    |    10%     |      no       | better:  2.7 times faster

MySqlInitialLoadMultiWorkerSingleTable | memory_mz       |        1125.410 |         958.266 |   MB   |    60%     |      no       | worse:  17.4% more

MySqlInitialLoadMultiWorkerSingleTable | memory_clusterd |         216.004 |         213.613 |   MB   |    60%     |      no       | worse:   1.1% more

Known trade-offs/risk areas

  1. Snapshotting may be ~10-20% slower for an existing workload if the work is well-distributed. Walking the tables to compute partition boundaries is an extra step that only improves wallclock performance when there are underutilized workers.
  2. MySQL may experience more sustained periods of high load in a customer environment due to the additional table scan up-front and due to the better utilization of workers on previously skewed workloads.
  3. Table read locks will be held for longer. The read locks are now all taken out by the leader and transactions need to be started by all workers before we release that read lock. This increases the duration during which the read locks are held to the slowest worker to start a transaction. It also decreases the granularity of read locking, so could result in some tables being locked for longer while we're waiting to acquire the lock on more actively written to tables.
  4. For places where we weren't fully utilizing the worker count -- i.e. 16 workers but 2 tables -- we'll be increasing the number of connections to mysql when we distribute the snapshot load across workers. MySQL by default has a relatively low max_connection count and Materialize will fail to connect if MySQL is saturated, potentially resulting in a retry loop.

@peterdukelarsen peterdukelarsen changed the title MySQL Parallel Snapshot [SS-97] MySQL Parallel Snapshot Jul 15, 2026
@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 4 times, most recently from 4408381 to 1816886 Compare July 16, 2026 19:33
@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 7 times, most recently from b538f70 to 32b6fd3 Compare July 17, 2026 15:27
peterdukelarsen added a commit that referenced this pull request Jul 17, 2026
### Motivation
Setting up tests and benchmarks for
#37642 ahead of time
where possible.

### Description
- 8-worker benchmark for a single 1M row table (which should get faster
if we parallelize)
- 8-worker benchmark for many tables to prevent regressions
- Varied test-drive tests to help ensure no regressions in correctness

### Verification

The first benchmark was used to show the speedup, the second caught a
regression in the original version of the PR, and the testdrive tests
should help with any obvious/simple correctness issues.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 13 times, most recently from d3285d8 to 32a4d3e Compare July 20, 2026 15:27
@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 2 times, most recently from 737d603 to 0352e90 Compare July 22, 2026 18:56
@peterdukelarsen

Copy link
Copy Markdown
Contributor Author

Do we also want hte mysql_source_snapshot_max_execution_time to apply to the PK boundary sampling? Currently it seems not to.

I think we do want to add this too -- making this change now.

@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 2 times, most recently from de9b1ec to aa33d1f Compare July 22, 2026 19:22
@def-

def- commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Given that we found a bunch of bugs, should we maybe have a way to fall back to non-parallel snapshotting with an LD flag, just in case?

@peterdukelarsen

Copy link
Copy Markdown
Contributor Author

Yeah, I'm open to a kill switch. I can easily fall back to the old partitioning logic with a flag. It might be a little harder to fall back to the old way of handling locking and opening up read transactions. I'll draft the easier version, but if anyone feels strongly about avoiding or having a kill switch for the new structure I can scope that out.

@peterdukelarsen
peterdukelarsen requested a review from a team as a code owner July 22, 2026 22:36
peterdukelarsen and others added 4 commits July 22, 2026 19:11
A BIT column maps to the UInt64 scalar type but carries a
MySqlColumnMeta::Bit marker. The PK-range sampler rendered its split
boundary from the scalar type alone, taking the integer path
CAST(id AS CHAR). For a BIT column that returns the value's raw bytes, not
a decimal literal, and the text was spliced unquoted into the worker range
predicates. A crafted BIT value could then inject SQL so every worker read
the whole table, duplicating rows.

Exclude PK columns that carry a column-meta marker from range splitting, so
they fall back to a single-worker whole-table read. Also validate that
integer-path boundary literals are decimal before using them, guarding
against any other type whose CAST-to-text is not a plain integer.
verify_schemas reads information_schema, which is not part of the repeatable-read
snapshot, so the per-worker check after the lock was released compared each output
against a live schema that need not match the snapshot the worker actually read.
It also ran after PK sampling, so a primary-key column renamed or dropped upstream
failed the sampling probe with a transient error that re-ran before verification
every restart, wedging the whole source in a loop.

Verify on the leader before sampling and locking. Outputs whose schema drifted are
dropped and excluded from sampling, so a renamed or dropped column surfaces as a
per-output definite error while the rest of the source keeps ingesting, with no
retry loop. Each worker still re-verifies in its read transaction, but any
incompatibility found there now means the schema moved after the leader's check,
so it returns a transient error and the snapshot retries from a consistent state.

The PK-boundary collation check moves off the leader's lock path the same way.
Instead of the leader validating monotonicity under the lock and downgrading
drifted tables to whole-table reads, each worker validates its ranged tables in
its read transaction and fails transiently on drift, so the retry re-samples
under the new collation. The leader's critical section shrinks to lock, read
GTID, broadcast. DDL after the worker checks is caught by the reads failing
with ER_TABLE_DEF_CHANGED.
Drop a redundant modulo in worker_pk_range that read like an
off-by-precedence bug, remove an unnecessary loop label, fix a typo and
a contradictory comment about the COUNT(*) fallback, and collapse a
double blank line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the mysql_source_snapshot_parallelism dyncfg, enabled by default, as
a kill switch for parallel PK-range snapshotting. When disabled the
leader skips boundary sampling, so every table takes the existing
single-worker whole-table fallback and the read-phase upstream
connection footprint drops back to one connection per responsible
worker. The leader-based lock/broadcast protocol is unaffected.

Registering the dyncfg automatically exposes it as a system var, so it
is settable via ALTER SYSTEM SET and manageable via LaunchDarkly.

Lists the flag in the test-infra parameter tables so CI can vary it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@peterdukelarsen

Copy link
Copy Markdown
Contributor Author

Pulled testdrive changes out here: #37819 to shrink the diff a little bit.

@martykulma martykulma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great @peterdukelarsen! The main concern I have is around all the extra work we now do while holding the lock for all tables. There's some low hanging fruit there in connection setup that we should address.

While not a blocker, I think we should give some serious considering to moving away from COUNT(*) for row count. As it stands, the implementation will perform 2 full index scans (one for the count, one for the offset queries) and then a full table scan (snapshot).

count is used in 2 places:

  1. statistics (and PG already uses an estimate for tables with > 1m rows)
  2. partitioning

My understanding is that a stale count affects the effectiveness of the partitioning and the accuracy of the statistics reporting, but it won't affect correctness.

// by the leader to publish the snapshot size gauge.
let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
let mut lock_conn = if is_snapshot_leader {
match lock_and_prepare_snapshot(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should try and minimize the time we're holding the lock. Right now, it looks like we pay connection costs for each worker under the lock. We can be more aggressive here and open the connections ahead of time. If there's no work, we'll just close them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems reasonable to me! Only downside to call out is we'll now have a higher peak of open connections, ~2x worker count (workers holding open a conn while waiting for the lock signal overlapping with the connections the leader uses to parallelize partition selection). I don't think that's a blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done b06b134

@martykulma

martykulma commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

While not a blocker, I think we should give some serious considering to moving away from COUNT(*) for row count

Forgot to mention that I'm good with adding that in a separate PR to avoid any additional complexity in this one.

@peterdukelarsen
peterdukelarsen force-pushed the pl/parallel-mysql branch 2 times, most recently from 593dea3 to d29b062 Compare July 23, 2026 19:34
peterdukelarsen and others added 2 commits July 23, 2026 15:50
Each worker connects to MySQL and validates replication settings before
awaiting the leader's broadcast, concurrently with the leader's PK
sampling and LOCK TABLES, shortening the window in which the leader
holds the read lock. The leader opens its own read connection the same
way, before it takes the locks. Workers that end up with no work, or
whose leader failed, drop their connection on exit, the same way the
completion path drops the read connection.

Setup now briefly holds up to 2 * worker_count + 1 upstream connections
per source, since idle worker connections overlap the leader's sampling
pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@peterdukelarsen
peterdukelarsen requested review from martykulma and removed request for martykulma July 23, 2026 20:10

@martykulma martykulma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice - lgtm!

Non-blocking, but small (and probably worth it). Those connections are going to sit idle for the duration of the partitioning. User may have overridden the default, so explicitly setting the wait timeout once those connections are established for defense.

SET @@session.wait_timeout = 28800 (this is the default )

@peterdukelarsen
peterdukelarsen dismissed def-’s stale review July 23, 2026 20:45

Seeing if this unblocks merging. I've addressed all of Dennis' review comments and he's approved the tests I split out to the next PR.

@peterdukelarsen
peterdukelarsen requested a review from a team as a code owner July 23, 2026 21:04
Worker snapshot connections sit idle while the leader samples PK bounds
and takes the table locks. On servers where the global wait_timeout is
lowered below that setup time, the server reaps the idle connections
and the snapshot restarts into the same wall. Explicitly restore the
MySQL default wait_timeout of 28800 seconds on each snapshot connection
once it is established.

Network middlebox idle timeouts are already covered by the existing
TCP keepalive configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants