Skip to main content

mz_storage/source/mysql/
snapshot.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Renders the table snapshot side of the [`MySqlSourceConnection`] dataflow.
11//!
12//! # Snapshot reading
13//!
14//! Depending on the `source_outputs resume_upper` parameters this dataflow decides which tables to
15//! snapshot and performs a simple `SELECT * FROM table` on them in order to get a snapshot.
16//! There are a few subtle points about this operation, described below.
17//!
18//! It is crucial for correctness that we always perform the snapshot of all tables at a specific
19//! point in time. This must be true even in the presence of restarts or partially committed
20//! snapshots. The consistent point that the snapshot must happen at is discovered and durably
21//! recorded during planning of the source and is exposed to this ingestion dataflow via the
22//! `initial_gtid_set` field in `MySqlSourceDetails`.
23//!
24//! Unfortunately MySQL does not provide an API to perform a transaction at a specific point in
25//! time. Instead, MySQL allows us to perform a snapshot of a table and let us know at which point
26//! in time the snapshot was taken. Using this information we can take a snapshot at an arbitrary
27//! point in time and then rewind it to the desired `initial_gtid_set` by "rewinding" it. These two
28//! phases are described in the following section.
29//!
30//! ## Producing a snapshot at a known point in time.
31//!
32//! Ideally we would like to start a transaction and ask MySQL to tell us the point in time this
33//! transaction is running at. As far as we know there isn't such API so we achieve this using
34//! table locks instead.
35//!
36//! A designated leader worker acquires table locks on all the tables to be snapshotted. By doing
37//! so we establish a moment in time where we know no writes are happening to the tables we are
38//! interested in. The leader then reads the current upper frontier (`snapshot_upper`) using the
39//! `@@gtid_executed` system variable and broadcasts it, along with PK-range bounds (see below), to
40//! all workers via a timely feedback loop. This frontier establishes an upper bound on any
41//! possible write to the tables of interest until the lock is released.
42//!
43//! Each worker now starts a transaction via a new connection with 'REPEATABLE READ' and
44//! 'CONSISTENT SNAPSHOT' semantics. Due to linearizability we know that this transaction's view of
45//! the database must some time `t_snapshot` such that `snapshot_upper <= t_snapshot`. We don't
46//! actually know the exact value of `t_snapshot` and it might be strictly greater than
47//! `snapshot_upper`. However, because this transaction will only be used to read the locked tables
48//! and we know that `snapshot_upper` is an upper bound on all the writes that have happened to
49//! them we can safely pretend that the transaction's `t_snapshot` is *equal* to `snapshot_upper`.
50//! We have therefore succeeded in starting a transaction at a known point in time!
51//!
52//! The leader verifies each output's schema against the planning-time desc before locking, and
53//! each worker re-verifies in its transaction, retrying transiently if the schema drifted since.
54//!
55//! Once all workers have started their transactions the leader unlocks the tables. Each worker
56//! then reads the snapshot of the tables (or PK ranges) it is responsible for and publishes it
57//! downstream.
58//!
59//! TODO: Other software products hold the table lock for the duration of the snapshot, and some do
60//! not. We should figure out why and if we need to hold the lock longer. This may be because of a
61//! difference in how REPEATABLE READ works in some MySQL-compatible systems (e.g. Aurora MySQL).
62//!
63//! ## Parallel PK-range snapshots
64//!
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
67//! reads only its assigned range. Ranges are assigned round-robin starting from each table's
68//! legacy single-worker owner, so the open-ended ranges (which absorb any rows written past the
69//! last sampled boundary) land on a different worker per table rather than always the last worker.
70//! Tables without a suitable PK fall back to single-worker-per-table mode. The
71//! `mysql_source_snapshot_parallelism` dyncfg disables splitting entirely, putting every table in
72//! that fallback mode. Workers open their connections while the leader samples and locks, so
73//! setup briefly holds up to `2 * worker_count + 1` upstream connections per source, settling to
74//! one per ranged worker plus the leader's lock connection. To handle various charsets and
75//! collation gracefully we rely on MySQL's sort order and never attempt to compare or order
76//! strings in Rust. To handle possible races with changes to collation each worker validates in
77//! its read transaction that the boundaries are strictly increasing under the table's current
78//! collation, retrying transiently if not. The repeatable read snapshots should then succeed if
79//! they start reading from the table before DDL runs, or if DDL does run before one of the
80//! workers reads the table, that worker's transaction should fail with an ER_TABLE_DEF_CHANGED.
81//!
82//! ## Rewinding the snapshot to a specific point in time.
83//!
84//! Having obtained a snapshot of a table at some `snapshot_upper` we are now tasked with
85//! transforming this snapshot into one at `initial_gtid_set`. In other words we have produced a
86//! snapshot containing all updates that happened at `t: !(snapshot_upper <= t)` but what we
87//! actually want is a snapshot containing all updates that happened at `t: !(initial_gtid <= t)`.
88//!
89//! If we assume that `initial_gtid_set <= snapshot_upper`, which is a fair assumption since the
90//! former is obtained before the latter, then we can observe that the snapshot we produced
91//! contains all updates at `t: !(initial_gtid <= t)` (i.e the snapshot we want) and some additional
92//! unwanted updates at `t: initial_gtid <= t && !(snapshot_upper <= t)`. We happen to know exactly
93//! what those additional unwanted updates are because those will be obtained by reading the
94//! replication stream in the replication operator and so all we need to do to "rewind" our
95//! `snapshot_upper` snapshot to `initial_gtid` is to ask the replication operator to "undo" any
96//! updates that falls in the undesirable region.
97//!
98//! This is exactly what `RewindRequest` is about. It informs the replication operator that a
99//! particular table has been snapshotted at `snapshot_upper` and would like all the updates
100//! discovered during replication that happen at `t: initial_gtid <= t && !(snapshot_upper <= t)`.
101//! to be cancelled. In Differential Dataflow this is as simple as flipping the sign of the diff
102//! field.
103//!
104//! The snapshot reader emits updates at the minimum timestamp (by convention) to allow the
105//! updates to be potentially negated by the replication operator, which will emit negated
106//! updates at the minimum timestamp (by convention) when it encounters rows from a table that
107//! occur before the GTID frontier in the Rewind Request for that table.
108use std::cell::RefCell;
109use std::collections::{BTreeMap, BTreeSet};
110use std::rc::Rc;
111use std::sync::Arc;
112use std::time::Duration;
113
114use differential_dataflow::AsCollection;
115use futures::{StreamExt as _, TryStreamExt};
116use itertools::Itertools;
117use mysql_async::prelude::Queryable;
118use mysql_async::{IsolationLevel, Row as MySqlRow, Transaction, TxOpts, Value};
119use mz_mysql_util::{
120    MySqlConn, MySqlError, QualifiedTableRef, pack_mysql_row, query_sys_var, quote_identifier,
121};
122use mz_ore::cast::CastFrom;
123use mz_ore::future::InTask;
124use mz_ore::iter::IteratorExt;
125use mz_ore::metrics::MetricsFutureExt;
126use mz_repr::{Diff, Row, SqlScalarType};
127use mz_storage_types::dyncfgs::STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD;
128use mz_storage_types::errors::DataflowError;
129use mz_storage_types::sources::MySqlSourceConnection;
130use mz_storage_types::sources::mysql::{GtidPartition, gtid_set_frontier};
131use mz_timely_util::antichain::AntichainExt;
132use mz_timely_util::builder_async::{
133    Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
134};
135use timely::container::CapacityContainerBuilder;
136use timely::dataflow::channels::pact::Pipeline;
137use timely::dataflow::operators::core::Map;
138use timely::dataflow::operators::vec::Broadcast;
139use timely::dataflow::operators::{CapabilitySet, Concat, ConnectLoop, Feedback};
140use timely::dataflow::{Scope, StreamVec};
141use timely::progress::Timestamp;
142use tracing::trace;
143
144use crate::metrics::source::mysql::MySqlSnapshotMetrics;
145use crate::source::RawSourceCreationConfig;
146use crate::source::types::{SharedFuel, SignaledFuture, SourceMessage, StackedCollection};
147use crate::statistics::SourceStatistics;
148
149use super::schemas::verify_schemas;
150use super::{
151    DefiniteError, MySqlTableName, ReplicationError, RewindRequest, SourceOutputInfo,
152    TransientError, return_definite_error, validate_mysql_repl_settings,
153};
154
155/// The raw (unquoted) name and scalar type of a table's primary key, when it is a
156/// single column. Callers quote the name for SQL predicates and use the raw name for
157/// `information_schema` lookups.
158fn try_extract_single_column_pk(
159    desc: &mz_mysql_util::MySqlTableDesc,
160) -> Option<(String, SqlScalarType)> {
161    let pk = desc.keys.iter().find(|k| k.is_primary)?;
162    let [name] = &pk.columns[..] else {
163        return None;
164    };
165    let col = desc.columns.iter().find(|c| &c.name == name)?;
166    if col.meta.is_some() {
167        return None;
168    }
169    let scalar_type = col.column_type.as_ref()?.scalar_type.clone();
170    Some((name.clone(), scalar_type))
171}
172
173#[derive(Clone, serde::Serialize, serde::Deserialize)]
174struct PkBoundaries {
175    pk_col: String,
176    // Ordered primary key values that partition the table space.
177    boundaries: Vec<String>,
178}
179
180// Ensure that boundaries are not printed because they contain data from the
181// primary key column.
182impl std::fmt::Debug for PkBoundaries {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("PkBoundaries")
185            .field("pk_col", &self.pk_col)
186            .field("boundaries", &mz_ore::str::redact(&self.boundaries))
187            .finish()
188    }
189}
190
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192struct SnapshotInfo {
193    gtid_set: String,
194    /// PK splits per table. None = no suitable PK, use single-worker fallback.
195    pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>>,
196    errored_outputs: Vec<(usize, DefiniteError)>,
197}
198
199struct PkRange {
200    /// Quoted PK column, e.g. `` `id` ``.
201    pk_col: String,
202    /// Inclusive lower bound key value, or `None` for the first partition (open start).
203    lower: Option<String>,
204    /// Exclusive upper bound key value, or `None` for the last partition (open end).
205    upper: Option<String>,
206}
207
208/// What a worker does for one table during the snapshot.
209enum ReadPlan {
210    /// Partitioned table: read this worker's assigned PK range.
211    Range(PkRange),
212    /// Unpartitioned table: this worker is responsible for it and reads it whole.
213    WholeTable,
214}
215
216/// This worker's PK range, or `None` if it owns no partition. Rotating the partition
217/// by the table's `owner` keeps the open-ended ranges from always landing on the same workers.
218fn worker_pk_range(
219    splits: &PkBoundaries,
220    worker_id: usize,
221    owner_worker_id: usize,
222    worker_count: usize,
223) -> Option<PkRange> {
224    let partition = (worker_id + worker_count - owner_worker_id) % worker_count;
225    let partitions = splits.boundaries.len() + 1;
226    if partition >= partitions {
227        return None;
228    }
229    Some(PkRange {
230        pk_col: splits.pk_col.clone(),
231        lower: (partition > 0).then(|| splits.boundaries[partition - 1].clone()),
232        upper: (partition < partitions - 1).then(|| splits.boundaries[partition].clone()),
233    })
234}
235
236const SUPPORTED_PK_COLLATION: &str = "utf8mb4_bin";
237const SUPPORTED_PK_CHARSET: &str = "utf8mb4";
238const MIN_PROBED_PREFIXES: u64 = 64;
239const MAX_PROBED_PREFIXES: u64 = 5_000;
240
241/// Partitioning configuration settings bundled to avoid accidental mixing.
242struct PartitionSettings {
243    min_rows: u64,
244    probed_prefixes_per_billion_rows: u64,
245}
246
247/// Attempts to compute roughly even boundaries for primary keys. Returns None if the column type
248/// is unsupported or boundaries couldn't be estimated for some reason. Currently only supports
249/// CHAR/VARCHAR columns with up to 768 characters with utf8mb4_bin collation.
250async fn compute_sampled_splits(
251    tx: &mut Transaction<'_>,
252    table: &MySqlTableName,
253    raw_col: &str,
254    scalar_type: &SqlScalarType,
255    worker_count: usize,
256    row_count: u64,
257    settings: &PartitionSettings,
258) -> Result<Option<PkBoundaries>, TransientError> {
259    match scalar_type {
260        SqlScalarType::Char { length }
261            if length.is_some_and(|l| l.into_u32() <= mz_mysql_util::MAX_KEY_LENGTH) => {}
262        SqlScalarType::VarChar { max_length }
263            if max_length.is_some_and(|l| l.into_u32() <= mz_mysql_util::MAX_KEY_LENGTH) => {}
264        _ => return Ok(None),
265    }
266    let collation = fetch_column_collation(&mut *tx, table, raw_col).await?;
267    let supported = matches!(&collation, Some(c) if c.1 == SUPPORTED_PK_COLLATION);
268    if !supported {
269        tracing::debug!(?collation, "PK splitting skipped: unsupported collation");
270        return Ok(None);
271    }
272    let table_ref = QualifiedTableRef {
273        schema_name: &table.0,
274        table_name: &table.1,
275    };
276    // For larger table sizes we can afford to spend more time computing partitions. Making thousands of
277    // network requests to search through prefixes can take minutes, but is worth it for billions of rows
278    // that can take hours to snapshot.
279    let max_probed_prefixes = (row_count.saturating_mul(settings.probed_prefixes_per_billion_rows)
280        / 1_000_000_000)
281        .clamp(MIN_PROBED_PREFIXES, MAX_PROBED_PREFIXES);
282    let params = mz_mysql_util::PartitionParams {
283        num_workers: worker_count,
284        estimated_row_count: row_count,
285        min_split_threshold: settings.min_rows,
286        max_probed_prefixes,
287    };
288    let prefixes = match mz_mysql_util::partition_table(tx, table_ref, raw_col, params).await {
289        Ok(prefixes) => prefixes,
290        Err(err @ (MySqlError::NonUtf8KeyValue { .. } | MySqlError::MissingRowEstimate { .. })) => {
291            tracing::warn!(%err, "partitioning failed, falling back to a single partition");
292            return Ok(None);
293        }
294        Err(err) => return Err(err.into()),
295    };
296    if prefixes.is_empty() {
297        return Ok(None);
298    }
299    Ok(Some(PkBoundaries {
300        pk_col: quote_identifier(raw_col),
301        boundaries: prefixes,
302    }))
303}
304
305/// For every table, read the row count (exact only for small tables) and, for a
306/// supported single-column primary key, compute the PK-range split boundaries,
307/// concurrently over at most `worker_count` connections. `None` bounds means
308/// single-worker fallback for that table. The counts are reused for both boundary
309/// discovery and the snapshot size gauge. The snapshot size gauge is a metric
310/// reporting how many rows the snapshot needs to process.
311async fn sample_pk_bounds(
312    config: &RawSourceCreationConfig,
313    connection_config: &mz_mysql_util::Config,
314    task_name: &str,
315    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
316    metrics: &MySqlSnapshotMetrics,
317) -> Result<
318    (
319        BTreeMap<MySqlTableName, Option<PkBoundaries>>,
320        BTreeMap<MySqlTableName, u64>,
321    ),
322    TransientError,
323> {
324    let ssh_tunnel_manager = &config.config.connection_context.ssh_tunnel_manager;
325    let worker_count = config.worker_count;
326    let max_execution_time = config
327        .config
328        .parameters
329        .mysql_source_timeouts
330        .snapshot_max_execution_time;
331    // Kill switch for PK-range splitting. When disabled every table gets `None`
332    // bounds, i.e. the single-worker-per-table fallback. The counts still run,
333    // they feed the snapshot size gauge.
334    let parallelism_enabled = mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARALLELISM
335        .get(config.config.config_set());
336    let exact_count_max_rows = u64::cast_from(
337        mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS
338            .get(config.config.config_set()),
339    );
340    let min_rows = u64::cast_from(
341        mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS
342            .get(config.config.config_set()),
343    );
344    let probed_prefixes_per_billion_rows = u64::cast_from(
345        mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARTITION_PROBED_PREFIXES_PER_BILLION_ROWS
346            .get(config.config.config_set()),
347    );
348    let partition_settings = &PartitionSettings {
349        min_rows,
350        probed_prefixes_per_billion_rows,
351    };
352
353    let pooled_conns: Rc<RefCell<Vec<MySqlConn>>> = Rc::new(RefCell::new(Vec::new()));
354    // Get row count and boundary estimates with worker-count concurrency.
355    let per_table: Vec<(MySqlTableName, u64, Option<PkBoundaries>)> = futures::stream::iter(tables)
356        .map(|(table, outputs)| {
357            let pool = Rc::clone(&pooled_conns);
358            async move {
359                // Grab the connection before the match to ensure the borrow is dropped
360                // before any awaits are called.
361                let pooled = pool.borrow_mut().pop();
362                let mut conn = match pooled {
363                    Some(conn) => conn,
364                    None => {
365                        let mut conn = connection_config
366                            .connect(task_name, ssh_tunnel_manager)
367                            .await?;
368                        if let Some(timeout) = max_execution_time {
369                            #[allow(clippy::disallowed_methods)]
370                            conn.query_drop(format!(
371                                "SET @@session.max_execution_time = {}",
372                                timeout.as_millis()
373                            ))
374                            .await?;
375                        }
376                        conn
377                    }
378                };
379                // Repeatable read required by the partitioner.
380                let mut tx_opts = TxOpts::default();
381                tx_opts
382                    .with_isolation_level(IsolationLevel::RepeatableRead)
383                    .with_readonly(true);
384                let mut tx = conn.start_transaction(tx_opts).await?;
385                // Row count, reused for boundary discovery and the size gauge. When it
386                // is counted exactly it runs on the same `READ ONLY` transaction as the
387                // boundary walk in `compute_sampled_splits`, so both see one consistent
388                // snapshot. For large tables it is an optimizer estimate instead, which
389                // `compute_sampled_splits` tolerates.
390                let stats = collect_table_statistics(&mut tx, table, exact_count_max_rows).await?;
391                metrics.record_table_count_latency(
392                    table.1.clone(),
393                    table.0.clone(),
394                    stats.count_latency,
395                );
396                let count = stats.count;
397                // Compute split boundaries only for a supported single-column PK.
398                let splits = match parallelism_enabled
399                    .then(|| try_extract_single_column_pk(&outputs[0].desc))
400                    .flatten()
401                {
402                    Some((raw_col, scalar_type)) => {
403                        compute_sampled_splits(
404                            &mut tx,
405                            table,
406                            &raw_col,
407                            &scalar_type,
408                            worker_count,
409                            count,
410                            partition_settings,
411                        )
412                        .await?
413                    }
414                    None => None,
415                };
416                // Ends the borrow of `conn`.
417                tx.rollback().await?;
418                pool.borrow_mut().push(conn);
419                Ok::<_, TransientError>((table.clone(), count, splits))
420            }
421        })
422        // At most `worker_count` connections are checked out at once, so the pool
423        // opens at most `min(worker_count, num_tables)` in total.
424        .buffer_unordered(worker_count)
425        .try_collect()
426        .await?;
427
428    let mut pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>> = BTreeMap::new();
429    let mut counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
430    for (table, count, splits) in per_table {
431        pk_bounds.insert(table.clone(), splits);
432        counts.insert(table, count);
433    }
434
435    // Every future has completed and dropped its `Rc` clone, so `pool` is the sole
436    // owner. Release the probe connections now, ending their `READ ONLY` transactions
437    // and the shared metadata locks they hold, before the caller takes `LOCK TABLES`.
438    let probe_conns = Rc::into_inner(pooled_conns)
439        .expect("all sampling futures completed, so no Rc clones remain")
440        .into_inner();
441    for conn in probe_conns {
442        conn.disconnect().await?;
443    }
444    Ok((pk_bounds, counts))
445}
446
447/// Leader-only snapshot setup: sample PK bounds, lock the tables `READ`, and read
448/// the snapshot GTID frontier. All fallible work happens here so the caller can
449/// always broadcast a result. A dropped `snapshot_cap_set` with no broadcast
450/// deadlocks the other workers waiting on the feedback loop.
451async fn lock_and_prepare_snapshot(
452    config: &RawSourceCreationConfig,
453    connection_config: &mz_mysql_util::Config,
454    task_name: &str,
455    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
456    metrics: &MySqlSnapshotMetrics,
457) -> Result<(SnapshotInfo, BTreeMap<MySqlTableName, u64>, MySqlConn), TransientError> {
458    let mut lock_conn = connection_config
459        .connect(
460            task_name,
461            &config.config.connection_context.ssh_tunnel_manager,
462        )
463        .await?;
464
465    if let Some(timeout) = config
466        .config
467        .parameters
468        .mysql_source_timeouts
469        .snapshot_wait_timeout
470    {
471        set_wait_timeout(&mut *lock_conn, timeout).await?;
472    }
473
474    let errored_outputs = verify_output_schemas(&mut *lock_conn, tables).await?;
475    let errored: BTreeSet<usize> = errored_outputs.iter().map(|(idx, _)| *idx).collect();
476    let sample_tables: BTreeMap<MySqlTableName, Vec<SourceOutputInfo>> = tables
477        .iter()
478        .map(|(table, outputs)| {
479            let outputs = outputs
480                .iter()
481                .filter(|o| !errored.contains(&o.output_index))
482                .cloned()
483                .collect::<Vec<_>>();
484            (table.clone(), outputs)
485        })
486        .filter(|(_, outputs)| !outputs.is_empty())
487        .collect();
488
489    // Sampling is expensive, so run it before locking writes.
490    let (pk_bounds, counts) = sample_pk_bounds(
491        config,
492        connection_config,
493        task_name,
494        &sample_tables,
495        metrics,
496    )
497    .await?;
498
499    let lock_clauses = sample_tables
500        .keys()
501        .map(|t| format!("{} READ", t))
502        .collect::<Vec<String>>()
503        .join(", ");
504
505    // TODO(roshan): Insert metric for how long it took to acquire the locks
506    let snapshot_gtid_set = lock_tables_and_read_gtid_set(
507        &mut lock_conn,
508        &lock_clauses,
509        config
510            .config
511            .parameters
512            .mysql_source_timeouts
513            .snapshot_lock_wait_timeout,
514    )
515    .await?;
516
517    Ok((
518        SnapshotInfo {
519            gtid_set: snapshot_gtid_set,
520            pk_bounds,
521            errored_outputs,
522        },
523        counts,
524        lock_conn,
525    ))
526}
527
528async fn verify_output_schemas<Q>(
529    conn: &mut Q,
530    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
531) -> Result<Vec<(usize, DefiniteError)>, TransientError>
532where
533    Q: Queryable,
534{
535    let errored = verify_schemas(
536        conn,
537        tables.iter().map(|(k, v)| (k, v.as_slice())).collect(),
538    )
539    .await?;
540    Ok(errored
541        .into_iter()
542        .map(|(output, err)| (output.output_index, err))
543        .collect())
544}
545
546/// Character set and collation of `column` in `table`, or `None` if the column has no
547/// collation (numeric/temporal types sort independently of collation) or is absent.
548async fn fetch_column_collation<Q>(
549    conn: &mut Q,
550    table: &MySqlTableName,
551    column: &str,
552) -> Result<Option<(String, String)>, TransientError>
553where
554    Q: Queryable,
555{
556    let row: Option<(Option<String>, Option<String>)> = conn
557        .exec_first(
558            "SELECT character_set_name, collation_name \
559             FROM information_schema.columns \
560             WHERE table_schema = ? AND table_name = ? AND column_name = ?",
561            (&table.0, &table.1, column),
562        )
563        .await?;
564    // The two are NULL together for a non-character column.
565    Ok(row.and_then(|(charset, collation)| Some((charset?, collation?))))
566}
567
568/// Whether `boundaries` are strictly increasing under `collation`. The half-open PK
569/// ranges only partition the table without gaps or overlaps when this holds. Each
570/// boundary is coerced to `charset`/`collation` so the comparison uses the same
571/// collation as the column, matching the read predicates. Fewer than two boundaries
572/// are trivially monotonic.
573async fn boundaries_strictly_monotonic<Q>(
574    conn: &mut Q,
575    boundaries: &[String],
576    charset: &str,
577    collation: &str,
578) -> Result<bool, TransientError>
579where
580    Q: Queryable,
581{
582    if boundaries.len() < 2 {
583        return Ok(true);
584    }
585    // `charset`/`collation` come from `information_schema` and can't be bound as parameters,
586    // so only interpolate the plain-identifier shape we expect. Anything else is unvalidated.
587    if !is_plain_ident(charset) || !is_plain_ident(collation) {
588        return Ok(false);
589    }
590    let term = format!("CONVERT(? USING {charset}) COLLATE {collation}");
591    let predicate = vec![format!("{term} < {term}"); boundaries.len() - 1].join(" AND ");
592    let params: Vec<Value> = boundaries
593        .windows(2)
594        .flat_map(|w| [w[0].as_str().into(), w[1].as_str().into()])
595        .collect();
596    let ok: Option<i64> = conn
597        .exec_first(format!("SELECT {predicate}"), params)
598        .await?;
599    Ok(ok == Some(1))
600}
601
602async fn verify_pk_bounds_monotonic<Q>(
603    tx: &mut Q,
604    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
605    table_ranges: &BTreeMap<MySqlTableName, ReadPlan>,
606    pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
607) -> Result<(), TransientError>
608where
609    Q: Queryable,
610{
611    for (table, plan) in table_ranges {
612        if !matches!(plan, ReadPlan::Range(_)) {
613            continue;
614        }
615        // A `Range` plan is only ever derived from populated bounds over a
616        // single-column PK recorded in the static source desc, so either
617        // lookup failing means `plan_worker_reads` and this check disagree.
618        let Some(Some(splits)) = pk_bounds.get(table) else {
619            return Err(TransientError::Generic(anyhow::anyhow!(
620                "PK range planned for {table} without any PK bounds, which is unexpected"
621            )));
622        };
623        let Some((raw_col, _)) = tables
624            .get(table)
625            .and_then(|outputs| try_extract_single_column_pk(&outputs[0].desc))
626        else {
627            return Err(TransientError::Generic(anyhow::anyhow!(
628                "PK range planned for {table} without a single-column PK, which is unexpected"
629            )));
630        };
631        let ok = match fetch_column_collation(tx, table, &raw_col).await? {
632            // The character set is always utf8mb4 for the utf8mb4_bin
633            // collation, so this is a sanity assertion.
634            Some((charset, collation))
635                if collation == SUPPORTED_PK_COLLATION && charset == SUPPORTED_PK_CHARSET =>
636            {
637                boundaries_strictly_monotonic(tx, &splits.boundaries, &charset, &collation).await?
638            }
639            // Populated PK bounds imply the split column was a supported string PK.
640            _ => false,
641        };
642        if !ok {
643            return Err(TransientError::Generic(anyhow::anyhow!(
644                "collation of {table} changed during snapshot setup"
645            )));
646        }
647    }
648    Ok(())
649}
650
651/// A plain SQL identifier: non-empty, only ASCII alphanumerics and underscores. Used to
652/// gate charset/collation names before interpolating them (they can't be parameters).
653fn is_plain_ident(s: &str) -> bool {
654    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
655}
656
657/// Returns the set of full tables/sections of tables to read.
658fn plan_worker_reads(
659    config: &RawSourceCreationConfig,
660    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
661    pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
662) -> BTreeMap<MySqlTableName, ReadPlan> {
663    tables
664        .keys()
665        .filter_map(|table| {
666            let plan = match pk_bounds.get(table) {
667                Some(Some(splits)) => worker_pk_range(
668                    splits,
669                    config.worker_id,
670                    config.responsible_worker(table),
671                    config.worker_count,
672                )
673                .map(ReadPlan::Range),
674                Some(None) => config
675                    .responsible_for(table)
676                    .then_some(ReadPlan::WholeTable),
677                None => panic!(
678                    "Programmer error: tables absent from pk_bounds failed schema \
679                     verification and are dropped before planning."
680                ),
681            };
682            plan.map(|plan| (table.clone(), plan))
683        })
684        .collect()
685}
686
687/// Renders the snapshot dataflow. See the module documentation for more information.
688pub(crate) fn render<'scope>(
689    scope: Scope<'scope, GtidPartition>,
690    config: RawSourceCreationConfig,
691    connection: MySqlSourceConnection,
692    source_outputs: Vec<SourceOutputInfo>,
693    metrics: MySqlSnapshotMetrics,
694) -> (
695    Vec<StackedCollection<'scope, GtidPartition, Result<SourceMessage, DataflowError>>>,
696    StreamVec<'scope, GtidPartition, RewindRequest>,
697    StreamVec<'scope, GtidPartition, ReplicationError>,
698    PressOnDropButton,
699) {
700    let mut builder =
701        AsyncOperatorBuilder::new(format!("MySqlSnapshotReader({})", config.id), scope.clone());
702
703    let (feedback_handle, feedback_data) = scope.feedback(Default::default());
704
705    // One data output port per source export, in output index order. With concurrent
706    // replication each port is held at the minimum only while this worker still has snapshot
707    // data to emit for that export. Without it all ports are held until the entire snapshot
708    // completes. Every export then observes the same frontier.
709    let export_count = config.source_exports.len();
710    let mut raw_handles = Vec::with_capacity(export_count);
711    let mut raw_streams = Vec::with_capacity(export_count);
712    for _ in 0..export_count {
713        let (handle, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
714        raw_handles.push(handle);
715        raw_streams.push(stream);
716    }
717    let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
718    // Captures DefiniteErrors that affect the entire source, including all outputs
719    let (definite_error_handle, definite_errors) =
720        builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
721    let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
722
723    // This operator needs to broadcast data to itself in order to synchronize the transaction
724    // snapshot. However, none of the feedback capabilities result in output messages and for the
725    // feedback edge specifically having a default connection would result in a loop.
726    let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
727
728    // The snapshot info must be sent to all workers, so we broadcast the feedback connection
729    snapshot.broadcast().connect_loop(feedback_handle);
730
731    let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
732
733    // A global view of all outputs that will be snapshot by all workers.
734    let mut all_outputs = vec![];
735    // The table infos to snapshot. Every worker holds all of them, since parallel
736    // PK-range reads split each table across workers.
737    let mut reader_snapshot_table_info = BTreeMap::new();
738    // Maps MySQL table name to export `SourceStatistics`. Same info exists in reader_snapshot_table_info,
739    // but this avoids having to iterate + map each time the statistics are needed.
740    let mut export_statistics = BTreeMap::new();
741    for output in source_outputs.into_iter() {
742        // Determine which outputs need to be snapshot and which already have been.
743        if *output.resume_upper != [GtidPartition::minimum()] {
744            // Already has been snapshotted.
745            continue;
746        }
747        all_outputs.push(output.output_index);
748        let export_stats = config
749            .statistics
750            .get(&output.export_id)
751            .expect("statistics have been intialized")
752            .clone();
753        export_statistics
754            .entry(output.table_name.clone())
755            .or_insert_with(Vec::new)
756            .push(export_stats);
757
758        reader_snapshot_table_info
759            .entry(output.table_name.clone())
760            .or_insert_with(Vec::new)
761            .push(output);
762    }
763
764    // Read at render time, matching the value the persist sink reads in the same dataflow build.
765    // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's
766    // creation and disagree with the sink because the `ConfigSet` is updated in place.
767    // A nonzero lookahead is the switch, see `STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD`.
768    let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD
769        .get(config.config.config_set())
770        .is_zero();
771
772    let (button, transient_errors): (_, StreamVec<'scope, GtidPartition, Rc<TransientError>>) =
773        builder.build_fallible(move |caps| {
774            let busy_signal = Arc::clone(&config.busy_signal);
775            Box::pin(SignaledFuture::new(busy_signal, async move {
776                let (data_cap_sets, caps) = caps.split_at_mut(export_count);
777                let [rewind_cap_set, definite_error_cap_set, snapshot_cap_set]: &mut [_; 3] =
778                    caps.try_into().unwrap();
779                let mut raw_handles = SharedFuel::new(raw_handles);
780
781                let id = config.id;
782                let worker_id = config.worker_id;
783
784                // This operator only emits snapshot data for the exports it snapshots. With
785                // concurrent replication the ports of all other exports close up front. Their
786                // frontiers then follow the replication operator alone. They make progress while
787                // the snapshot runs.
788                if concurrent_replication {
789                    release_data_ports(data_cap_sets, &all_outputs.iter().copied().collect());
790                }
791
792                if !all_outputs.is_empty() {
793                    // A worker *must* emit a count even if not responsible for snapshotting a table
794                    // as statistic summarization will return null if any worker hasn't set a value.
795                    // This will also reset snapshot stats for any exports not snapshotting.
796                    for statistics in config.statistics.values() {
797                        statistics.set_snapshot_records_known(0);
798                        statistics.set_snapshot_records_staged(0);
799                    }
800                }
801
802                // If this worker has no tables to snapshot then there is nothing to do.
803                if reader_snapshot_table_info.is_empty() {
804                    trace!(%id, "timely-{worker_id} initializing table reader \
805                                 with no tables to snapshot, exiting");
806                    return Ok(());
807                } else {
808                    trace!(%id, "timely-{worker_id} initializing table reader \
809                                 with {} tables to snapshot",
810                           reader_snapshot_table_info.len());
811                }
812
813                let connection_config = connection
814                    .connection
815                    .config(
816                        &config.config.connection_context.secrets_reader,
817                        &config.config,
818                        InTask::Yes,
819                    )
820                    .await?;
821                let task_name = format!("timely-{worker_id} MySQL snapshotter");
822
823                // Per-table row counts, computed once during PK sampling and reused
824                // by the leader to publish the snapshot size gauge.
825                let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
826
827                let mut conn = connection_config
828                    .connect(
829                        &task_name,
830                        &config.config.connection_context.ssh_tunnel_manager,
831                    )
832                    .await?;
833
834                // Verify the MySQL system settings are correct for consistent row-based replication using GTIDs
835                match validate_mysql_repl_settings(&mut conn).await {
836                    Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
837                        return Ok(return_definite_error(
838                            DefiniteError::ServerConfigurationError(err.to_string()),
839                            &all_outputs,
840                            &mut raw_handles,
841                            data_cap_sets,
842                            &definite_error_handle,
843                            definite_error_cap_set,
844                        )
845                        .await);
846                    }
847                    Err(err) => Err(err)?,
848                    Ok(()) => (),
849                };
850
851                if let Some(timeout) = config
852                    .config
853                    .parameters
854                    .mysql_source_timeouts
855                    .snapshot_wait_timeout
856                {
857                    set_wait_timeout(&mut *conn, timeout).await?;
858                }
859
860                let mut lock_conn = if is_snapshot_leader {
861                    match lock_and_prepare_snapshot(
862                        &config,
863                        &connection_config,
864                        &task_name,
865                        &reader_snapshot_table_info,
866                        &metrics,
867                    )
868                    .await
869                    {
870                        Ok((info, counts, conn)) => {
871                            snapshot_counts = counts;
872                            trace!(%id, "timely-{worker_id} broadcasting snapshot info: {info:?}");
873                            snapshot_handle.give(&snapshot_cap_set[0], Some(info));
874                            Some(conn)
875                        }
876                        Err(err) => {
877                            // Broadcast the failure sentinel so non-leaders exit cleanly instead
878                            // of deadlocking on the feedback loop.
879                            snapshot_handle.give(&snapshot_cap_set[0], None);
880                            return Err(err);
881                        }
882                    }
883                } else {
884                    None
885                };
886
887                // Receive the leader's broadcast: `Some` on success, `None` on leader failure.
888                let snapshot_info: Option<SnapshotInfo> = loop {
889                    match snapshot_input.next().await {
890                        Some(AsyncEvent::Data(_, mut data)) => {
891                            if let Some(msg) = data.pop() {
892                                break msg;
893                            }
894                        }
895                        Some(AsyncEvent::Progress(_)) => continue,
896                        // Feedback closed without data: the leader failed and already
897                        // propagated the error.
898                        None => break None,
899                    }
900                };
901                let snapshot_info = match snapshot_info {
902                    Some(info) => info,
903                    None => return Ok(()),
904                };
905
906                let errored: BTreeMap<usize, DefiniteError> =
907                    snapshot_info.errored_outputs.iter().cloned().collect();
908                let errored_outputs: Vec<_> = reader_snapshot_table_info
909                    .values()
910                    .flatten()
911                    .filter_map(|output| {
912                        errored.get(&output.output_index).map(|err| (output, err))
913                    })
914                    .collect();
915                let mut removed_outputs = BTreeSet::new();
916                for (output, err) in errored_outputs {
917                    removed_outputs.insert(output.output_index);
918                    // Only the responsible worker publishes any error,
919                    // so it lands once instead of once per worker reading the table.
920                    if !config.responsible_for(&output.table_name) {
921                        continue;
922                    }
923                    let output_index = output.output_index;
924                    let update = (Err(err.clone().into()), GtidPartition::minimum(), Diff::ONE);
925                    raw_handles
926                        .give(output_index, &data_cap_sets[output_index][0], update)
927                        .await;
928                    tracing::warn!(%id, "timely-{worker_id} stopping snapshot of output {output:?} \
929                                due to schema mismatch");
930                }
931                for (_, outputs) in reader_snapshot_table_info.iter_mut() {
932                    outputs.retain(|output| !removed_outputs.contains(&output.output_index));
933                }
934                reader_snapshot_table_info.retain(|_, outputs| !outputs.is_empty());
935
936                let snapshot_gtid_frontier = match gtid_set_frontier(&snapshot_info.gtid_set) {
937                    Ok(frontier) => frontier,
938                    Err(err) => {
939                        // If we received a GTID Set with non-consecutive intervals this breaks all
940                        // our assumptions, so there is nothing else we can do.
941                        return Ok(return_definite_error(
942                            DefiniteError::UnsupportedGtidState(err.to_string()),
943                            &all_outputs,
944                            &mut raw_handles,
945                            data_cap_sets,
946                            &definite_error_handle,
947                            definite_error_cap_set,
948                        )
949                        .await);
950                    }
951                };
952
953                trace!(%id, "timely-{worker_id} received snapshot info at: {}",
954                       snapshot_gtid_frontier.pretty());
955
956                // Several workers can read parts of a partitioned table. To avoid duplicate rewind
957                // requests, only the worker responsible for the table emits them.
958                let emit_rewinds = |rewind_cap_set: &mut CapabilitySet<GtidPartition>| {
959                    for (table, outputs) in &reader_snapshot_table_info {
960                        if !config.responsible_for(table) {
961                            continue;
962                        }
963                        for output in outputs {
964                            trace!(%id, "timely-{worker_id} producing rewind request for {table}\
965                                         output {}", output.output_index);
966                            let req = RewindRequest {
967                                output_index: output.output_index,
968                                snapshot_upper: snapshot_gtid_frontier.clone(),
969                            };
970                            rewinds_handle.give(&rewind_cap_set[0], req);
971                        }
972                    }
973                    *rewind_cap_set = CapabilitySet::new();
974                };
975
976                // The rewind requests unblock the replication operator. With concurrent
977                // replication they go out now, before any data is copied. The snapshot frontier is
978                // already known at this point. The replication operator then reads the stream
979                // while the snapshot runs and stages its data in the dataflow until the snapshot
980                // completes. Without concurrent replication they go out after the snapshot. That
981                // keeps the two phases serial and avoids the staging cost.
982                if concurrent_replication {
983                    emit_rewinds(rewind_cap_set);
984                }
985
986                let table_ranges = plan_worker_reads(
987                    &config,
988                    &reader_snapshot_table_info,
989                    &snapshot_info.pk_bounds,
990                );
991                let has_work = !table_ranges.is_empty();
992
993                // Ports of snapshotted exports close as soon as this worker has nothing left to
994                // emit for them. At this point that covers outputs that failed schema
995                // verification. It also covers tables this worker reads no part of.
996                if concurrent_replication {
997                    let reading = reader_snapshot_table_info
998                        .iter()
999                        .filter(|(table, _)| table_ranges.contains_key(*table))
1000                        .flat_map(|(_, outputs)| outputs.iter().map(|output| output.output_index))
1001                        .collect();
1002                    release_data_ports(data_cap_sets, &reading);
1003                }
1004
1005                // Returning will release the snapshot capabilities unblocking the leader from dropping the lock connection.
1006                if !has_work && !is_snapshot_leader {
1007                    trace!(%id, "timely-{worker_id} has no tables to snapshot.");
1008                    return Ok(());
1009                }
1010
1011                trace!(%id, "timely-{worker_id} starting transaction with \
1012                             consistent snapshot at: {}", snapshot_gtid_frontier.pretty());
1013
1014                // Start a transaction with REPEATABLE READ and 'CONSISTENT SNAPSHOT' semantics
1015                // so we can read a consistent snapshot of the table at the specific GTID we read.
1016                let mut tx_opts = TxOpts::default();
1017                tx_opts
1018                    .with_isolation_level(IsolationLevel::RepeatableRead)
1019                    .with_consistent_snapshot(true)
1020                    .with_readonly(true);
1021                let mut tx = conn.start_transaction(tx_opts).await?;
1022                // Set the session time zone to UTC so that we can read TIMESTAMP columns as UTC
1023                // From https://dev.mysql.com/doc/refman/8.0/en/datetime.html: "MySQL converts TIMESTAMP values
1024                // from the current time zone to UTC for storage, and back from UTC to the current time zone
1025                // for retrieval. (This does not occur for other types such as DATETIME.)"
1026                #[allow(clippy::disallowed_methods)] // static SQL string
1027                tx.query_drop("set @@session.time_zone = '+00:00'").await?;
1028
1029                // Configure query execution time based on param. We want to be able to
1030                // override the server value here in case it's set too low,
1031                // respective to the size of the data we need to copy.
1032                if let Some(timeout) = config
1033                    .config
1034                    .parameters
1035                    .mysql_source_timeouts
1036                    .snapshot_max_execution_time
1037                {
1038                    // Interpolating an integer millis value; not parameterizable in MySQL `SET`.
1039                    #[allow(clippy::disallowed_methods)]
1040                    tx.query_drop(format!(
1041                        "SET @@session.max_execution_time = {}",
1042                        timeout.as_millis()
1043                    ))
1044                    .await?;
1045                }
1046
1047                // Signal readiness by dropping the snapshot capability, then the leader
1048                // waits for every worker to signal before unlocking.
1049                *snapshot_cap_set = CapabilitySet::new();
1050                if is_snapshot_leader {
1051                    while snapshot_input.next().await.is_some() {}
1052                    if let Some(mut lc) = lock_conn.take() {
1053                        #[allow(clippy::disallowed_methods)] // static SQL string
1054                        lc.query_drop("UNLOCK TABLES").await?;
1055                        lc.disconnect().await?;
1056                    }
1057                }
1058                drop(lock_conn);
1059
1060                trace!(%id, "timely-{worker_id} started transaction (has_work={has_work}, is_snapshot_leader={is_snapshot_leader})");
1061
1062                // Verify the schemas of the tables we are snapshotting
1063                let errored_outputs = verify_schemas(
1064                    &mut tx,
1065                    reader_snapshot_table_info
1066                        .iter()
1067                        .filter(|(t, _)| table_ranges.contains_key(t))
1068                        .map(|(k, v)| (k, v.as_slice()))
1069                        .collect(),
1070                )
1071                .await?;
1072                if let Some((output, err)) = errored_outputs.into_iter().next() {
1073                    return Err(TransientError::Generic(anyhow::anyhow!(
1074                        "schema of {} changed during snapshot setup: {err}",
1075                        output.table_name
1076                    )));
1077                }
1078                verify_pk_bounds_monotonic(
1079                    &mut tx,
1080                    &reader_snapshot_table_info,
1081                    &table_ranges,
1082                    &snapshot_info.pk_bounds,
1083                )
1084                .await?;
1085
1086                // Only the leader publishes the full snapshot size, so the summed
1087                // worker-local gauges reflect the upstream total without double-counting.
1088                // The counts were computed once during PK sampling and are reused here.
1089                if is_snapshot_leader {
1090                    publish_snapshot_size(
1091                        &snapshot_counts,
1092                        &reader_snapshot_table_info,
1093                        &export_statistics,
1094                    );
1095                }
1096
1097                // This worker has nothing else to do
1098                if reader_snapshot_table_info.is_empty() {
1099                    return Ok(());
1100                }
1101
1102                // Read the snapshot data from the tables
1103                let mut final_row = Row::default();
1104
1105                let mut snapshot_staged_total = 0;
1106                for (table, outputs) in &reader_snapshot_table_info {
1107                    let pk_range = match table_ranges.get(table) {
1108                        Some(ReadPlan::Range(range)) => Some(range),
1109                        Some(ReadPlan::WholeTable) => None,
1110                        // This worker has no work for this table.
1111                        None => continue,
1112                    };
1113
1114                    mysql_snapshot_failpoint_pause(&table.1).await;
1115
1116                    let mut snapshot_staged = 0;
1117                    let (query, params) = build_snapshot_query(outputs, pk_range);
1118                    trace!(%id, "timely-{worker_id} reading snapshot query='{}'", query);
1119                    let mut results = tx.exec_stream(query, params).await?;
1120                    while let Some(row) = results.try_next().await? {
1121                        let row: MySqlRow = row;
1122                        snapshot_staged += 1;
1123                        for (output, row_val) in outputs.iter().repeat_clone(row) {
1124                            // We don't need to verify if binlog_row_metadata matches the expected when snapshotting as
1125                            // the snapshot query always returns rows with full metadata. If the output is configured
1126                            // with binlog_full_metadata = false, then we will just ignore the metadata when decoding.
1127                            let event = match pack_mysql_row(
1128                                &mut final_row,
1129                                row_val,
1130                                &output.desc,
1131                                None,
1132                                output.binlog_full_metadata,
1133                            ) {
1134                                Ok(row) => Ok(SourceMessage {
1135                                    key: Row::default(),
1136                                    value: row,
1137                                    metadata: Row::default(),
1138                                }),
1139                                // Produce a DefiniteError in the stream for any rows that fail to decode
1140                                Err(err @ MySqlError::ValueDecodeError { .. }) => {
1141                                    Err(DataflowError::from(DefiniteError::ValueDecodeError(
1142                                        err.to_string(),
1143                                    )))
1144                                }
1145                                Err(err) => Err(err)?,
1146                            };
1147                            let output_index = output.output_index;
1148                            let update = (event, GtidPartition::minimum(), Diff::ONE);
1149                            raw_handles
1150                                .give(output_index, &data_cap_sets[output_index][0], update)
1151                                .await;
1152                        }
1153                        // This overcounting maintains existing behavior but will be removed once readers no longer rely on the value.
1154                        snapshot_staged_total += u64::cast_from(outputs.len());
1155                        if snapshot_staged_total % 1000 == 0 {
1156                            for statistics in export_statistics.get(table).unwrap() {
1157                                statistics.set_snapshot_records_staged(snapshot_staged);
1158                            }
1159                        }
1160                    }
1161                    for statistics in export_statistics.get(table).unwrap() {
1162                        statistics.set_snapshot_records_staged(snapshot_staged);
1163                    }
1164                    trace!(%id, "timely-{worker_id} snapshotted {} records from \
1165                                 table '{table}'", snapshot_staged * u64::cast_from(outputs.len()));
1166                    // A transient error above returns with the capabilities still held. That
1167                    // keeps a partially copied export from advancing.
1168                    if concurrent_replication {
1169                        for output in outputs {
1170                            data_cap_sets[output.output_index] = CapabilitySet::new();
1171                        }
1172                    }
1173                }
1174
1175                if !concurrent_replication {
1176                    emit_rewinds(rewind_cap_set);
1177                }
1178
1179                Ok(())
1180            }))
1181        });
1182
1183    // TODO: Split row decoding into a separate operator that can be distributed across all workers
1184
1185    let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
1186
1187    (
1188        raw_streams
1189            .into_iter()
1190            .map(|stream| stream.as_collection())
1191            .collect(),
1192        rewinds,
1193        errors,
1194        button.press_on_drop(),
1195    )
1196}
1197
1198/// Test-only pause point, run before this worker reads any rows of `table`. Configuring the
1199/// failpoint as `mysql_snapshot_pause=return(<table name>)` holds the snapshot of that table until
1200/// the failpoint is deactivated. `return(<table name>:<millis>)` holds it for that many
1201/// milliseconds instead. The snapshot then completes in the same dataflow incarnation.
1202async fn mysql_snapshot_failpoint_pause(table: &str) {
1203    let held_since = std::time::Instant::now();
1204    loop {
1205        let held = fail::eval("mysql_snapshot_pause", |payload| {
1206            let (paused_table, millis) = payload
1207                .as_deref()
1208                .map(|p| p.split_once(':').unwrap_or((p, "")))
1209                .unwrap_or_default();
1210            paused_table == table
1211                && millis.parse::<u64>().map_or(true, |millis| {
1212                    held_since.elapsed() < Duration::from_millis(millis)
1213                })
1214        });
1215        match held {
1216            Some(true) => tokio::time::sleep(Duration::from_millis(100)).await,
1217            _ => break,
1218        }
1219    }
1220}
1221
1222/// Drops the capabilities of every data port whose output index is not in `keep`.
1223fn release_data_ports(data_cap_sets: &mut [CapabilitySet<GtidPartition>], keep: &BTreeSet<usize>) {
1224    for (output_index, cap_set) in data_cap_sets.iter_mut().enumerate() {
1225        if !keep.contains(&output_index) {
1226            *cap_set = CapabilitySet::new();
1227        }
1228    }
1229}
1230
1231/// Publish the snapshot size to each table's statistics gauges, using the counts
1232/// computed once during PK sampling. Called leader-only so the summed worker-local gauges
1233/// reflect the upstream total without double-counting.
1234fn publish_snapshot_size(
1235    counts: &BTreeMap<MySqlTableName, u64>,
1236    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
1237    export_statistics: &BTreeMap<MySqlTableName, Vec<SourceStatistics>>,
1238) {
1239    for name in tables.keys() {
1240        let count = counts.get(name).copied().unwrap_or(0);
1241        let stats = export_statistics
1242            .get(name)
1243            .expect("statistics are initialized for each output");
1244        for export_stat in stats {
1245            export_stat.set_snapshot_records_known(count);
1246            export_stat.set_snapshot_records_staged(0);
1247        }
1248    }
1249}
1250
1251/// Sets the session wait_timeout so a lowered global value cannot reap the
1252/// connection while it sits idle during snapshot setup.
1253async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1254where
1255    Q: Queryable,
1256{
1257    // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1258    #[allow(clippy::disallowed_methods)]
1259    conn.query_drop(format!(
1260        "SET @@session.wait_timeout = {}",
1261        timeout.as_secs()
1262    ))
1263    .await
1264}
1265async fn lock_tables_and_read_gtid_set(
1266    lock_conn: &mut MySqlConn,
1267    lock_clauses: &str,
1268    lock_wait_timeout: Option<Duration>,
1269) -> Result<String, TransientError> {
1270    if let Some(timeout) = lock_wait_timeout {
1271        // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1272        #[allow(clippy::disallowed_methods)]
1273        lock_conn
1274            .query_drop(format!(
1275                "SET @@session.lock_wait_timeout = {}",
1276                timeout.as_secs()
1277            ))
1278            .await?;
1279    }
1280
1281    // `lock_clauses` is built from `MySqlTableName::Display`, which escapes both
1282    // schema and table via `quote_identifier`.
1283    if !lock_clauses.is_empty() {
1284        #[allow(clippy::disallowed_methods)]
1285        lock_conn
1286            .query_drop(format!("LOCK TABLES {lock_clauses}"))
1287            .await?;
1288    }
1289
1290    let snapshot_gtid_set = query_sys_var(lock_conn, "global.gtid_executed").await?;
1291    Ok(snapshot_gtid_set)
1292}
1293
1294/// Builds the SQL query to be used for creating the snapshot using the first entry in outputs.
1295///
1296/// Expect `outputs` to contain entries for a single table, and to have at least 1 entry.
1297/// Expect that each MySqlTableDesc entry contains all columns described in information_schema.columns.
1298///
1299/// When `pk_range` is provided, a WHERE clause is appended to filter rows by PK range.
1300#[must_use]
1301fn build_snapshot_query(
1302    outputs: &[SourceOutputInfo],
1303    pk_range: Option<&PkRange>,
1304) -> (String, Vec<Value>) {
1305    let info = outputs.first().expect("MySQL table info");
1306    for output in &outputs[1..] {
1307        // the columns may be decoded based on position, and different outputs may replicate
1308        // different columns, so we need to ensure that all columns are accounted for.
1309        assert!(
1310            info.desc.columns.len() == output.desc.columns.len(),
1311            "Mismatch in table descriptions for {}",
1312            info.table_name
1313        );
1314    }
1315    let columns = info
1316        .desc
1317        .columns
1318        .iter()
1319        .map(|col| quote_identifier(&col.name))
1320        .join(", ");
1321    let mut query = format!("SELECT {} FROM {}", columns, info.table_name);
1322    let mut params: Vec<Value> = vec![];
1323    if let Some(range) = pk_range {
1324        // Half-open range on the PK column. The first/last partition omits its
1325        // open bound.
1326        let col = &range.pk_col;
1327        if let Some(lower) = &range.lower {
1328            query.push_str(&format!(" WHERE {col} >= ?"));
1329            params.push(lower.as_str().into());
1330        }
1331        if let Some(upper) = &range.upper {
1332            let kw = if range.lower.is_some() {
1333                "AND"
1334            } else {
1335                "WHERE"
1336            };
1337            query.push_str(&format!(" {kw} {col} < ?"));
1338            params.push(upper.as_str().into());
1339        }
1340    }
1341    (query, params)
1342}
1343
1344#[derive(Default)]
1345struct TableStatistics {
1346    count_latency: f64,
1347    count: u64,
1348}
1349
1350/// Row count for the snapshot size gauge. Tables whose optimizer row estimate exceeds
1351/// `exact_count_max_rows` report the estimate directly, everything else is counted
1352/// exactly with `COUNT(*)`. The gauge only drives progress reporting, and an estimate is
1353/// a fair trade for skipping an O(rows) index walk on a large table.
1354async fn collect_table_statistics<Q>(
1355    conn: &mut Q,
1356    table: &MySqlTableName,
1357    exact_count_max_rows: u64,
1358) -> Result<TableStatistics, TransientError>
1359where
1360    Q: Queryable,
1361{
1362    let mut stats = TableStatistics::default();
1363
1364    // The optimizer's row estimate for the table. InnoDB keeps it roughly current
1365    // (within the churn since the last stats recalculation), but it can be
1366    // stale-at-zero, in which case we fall through to the exact count. We don't expect
1367    // it to be null, but also fall back to count(*) in that case.
1368    let estimate: Option<Option<u64>> = conn
1369        .exec_first(
1370            "SELECT table_rows FROM information_schema.tables \
1371             WHERE table_schema = ? AND table_name = ?",
1372            (&table.0, &table.1),
1373        )
1374        .wall_time()
1375        .set_at(&mut stats.count_latency)
1376        .await?;
1377    match estimate.flatten() {
1378        Some(estimate) if estimate > exact_count_max_rows => {
1379            stats.count = estimate;
1380        }
1381        _ => {
1382            // `MySqlTableName::Display` escapes both identifier components via
1383            // `quote_identifier`, so this interpolation is safe; not parameterizable.
1384            #[allow(clippy::disallowed_methods)]
1385            let count_row: Option<u64> = conn
1386                .query_first(format!("SELECT COUNT(*) FROM {}", table))
1387                .wall_time()
1388                .set_at(&mut stats.count_latency)
1389                .await?;
1390            // `COUNT(*)` returns exactly one row, so `None` should be impossible.
1391            // Default to 0 defensively rather than failing the snapshot on a protocol
1392            // quirk.
1393            stats.count = count_row.unwrap_or(0);
1394        }
1395    }
1396
1397    Ok(stats)
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403    use mz_mysql_util::{MySqlColumnDesc, MySqlTableDesc};
1404    use timely::progress::Antichain;
1405
1406    #[mz_ore::test]
1407    fn snapshot_query_duplicate_table() {
1408        let schema_name = "myschema".to_string();
1409        let table_name = "mytable".to_string();
1410        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1411        let columns = ["c1", "c2", "c3"]
1412            .iter()
1413            .map(|col| MySqlColumnDesc {
1414                name: col.to_string(),
1415                column_type: None,
1416                meta: None,
1417            })
1418            .collect::<Vec<_>>();
1419        let desc = MySqlTableDesc {
1420            schema_name: schema_name.clone(),
1421            name: table_name.clone(),
1422            columns,
1423            keys: BTreeSet::default(),
1424        };
1425        let info = SourceOutputInfo {
1426            output_index: 1, // ignored
1427            table_name: table.clone(),
1428            desc,
1429            text_columns: vec![],
1430            exclude_columns: vec![],
1431            initial_gtid_set: Antichain::default(),
1432            resume_upper: Antichain::default(),
1433            export_id: mz_repr::GlobalId::User(1),
1434            binlog_full_metadata: false,
1435        };
1436        let (query, _) = build_snapshot_query(&[info.clone(), info], None);
1437        assert_eq!(
1438            format!(
1439                "SELECT `c1`, `c2`, `c3` FROM `{}`.`{}`",
1440                schema_name, table_name
1441            ),
1442            query
1443        );
1444    }
1445
1446    #[mz_ore::test]
1447    fn snapshot_query_with_pk_range() {
1448        let schema_name = "myschema".to_string();
1449        let table_name = "mytable".to_string();
1450        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1451        let columns = ["id", "name"]
1452            .iter()
1453            .map(|col| MySqlColumnDesc {
1454                name: col.to_string(),
1455                column_type: None,
1456                meta: None,
1457            })
1458            .collect::<Vec<_>>();
1459        let desc = MySqlTableDesc {
1460            schema_name: schema_name.clone(),
1461            name: table_name.clone(),
1462            columns,
1463            keys: BTreeSet::default(),
1464        };
1465        let info = SourceOutputInfo {
1466            output_index: 1,
1467            table_name: table.clone(),
1468            desc,
1469            text_columns: vec![],
1470            exclude_columns: vec![],
1471            initial_gtid_set: Antichain::default(),
1472            resume_upper: Antichain::default(),
1473            export_id: mz_repr::GlobalId::User(1),
1474            binlog_full_metadata: false,
1475        };
1476
1477        // Middle worker: both bounds.
1478        let range = PkRange {
1479            pk_col: "`id`".to_string(),
1480            lower: Some("100".to_string()),
1481            upper: Some("200".to_string()),
1482        };
1483        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1484        assert_eq!(
1485            format!(
1486                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ? AND `id` < ?",
1487                schema_name, table_name
1488            ),
1489            query
1490        );
1491        assert_eq!(params, vec![Value::from("100"), Value::from("200")]);
1492
1493        // First worker: open start.
1494        let range = PkRange {
1495            pk_col: "`id`".to_string(),
1496            lower: None,
1497            upper: Some("200".to_string()),
1498        };
1499        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1500        assert_eq!(
1501            format!(
1502                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` < ?",
1503                schema_name, table_name
1504            ),
1505            query
1506        );
1507        assert_eq!(params, vec![Value::from("200")]);
1508
1509        // Last worker: open end.
1510        let range = PkRange {
1511            pk_col: "`id`".to_string(),
1512            lower: Some("200".to_string()),
1513            upper: None,
1514        };
1515        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1516        assert_eq!(
1517            format!(
1518                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ?",
1519                schema_name, table_name
1520            ),
1521            query
1522        );
1523        assert_eq!(params, vec![Value::from("200")]);
1524    }
1525
1526    #[mz_ore::test]
1527    fn test_worker_pk_range() {
1528        // Two partitions, boundary at 51. Owner 0 is the identity mapping
1529        // (partition == worker_id).
1530        let splits = PkBoundaries {
1531            pk_col: "`id`".to_string(),
1532            boundaries: vec!["51".to_string()],
1533        };
1534        let r0 = worker_pk_range(&splits, 0, 0, 4).expect("worker 0");
1535        assert_eq!(r0.pk_col, "`id`");
1536        assert_eq!(r0.lower, None); // open start
1537        assert_eq!(r0.upper.as_deref(), Some("51"));
1538        let r1 = worker_pk_range(&splits, 1, 0, 4).expect("worker 1");
1539        assert_eq!(r1.lower.as_deref(), Some("51"));
1540        assert_eq!(r1.upper, None); // open end
1541        // A surplus worker beyond the partition count has no work.
1542        assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1543
1544        // Three partitions: the middle worker has both bounds.
1545        let splits = PkBoundaries {
1546            pk_col: "`id`".to_string(),
1547            boundaries: vec!["34".to_string(), "67".to_string()],
1548        };
1549        let r1 = worker_pk_range(&splits, 1, 0, 3).expect("worker 1");
1550        assert_eq!(r1.lower.as_deref(), Some("34"));
1551        assert_eq!(r1.upper.as_deref(), Some("67"));
1552
1553        // Offsetting by the owner still assigns every worker a distinct partition,
1554        // and the owner reads the open-started first partition.
1555        let owner = 2;
1556        let owned = worker_pk_range(&splits, owner, owner, 3).expect("owner has work");
1557        assert_eq!(owned.lower, None);
1558        let mut ranges: Vec<_> = (0..3)
1559            .map(|w| {
1560                let r = worker_pk_range(&splits, w, owner, 3).expect("worker has work");
1561                (r.lower, r.upper)
1562            })
1563            .collect();
1564        ranges.sort();
1565        assert_eq!(
1566            ranges,
1567            vec![
1568                (None, Some("34".to_string())),
1569                (Some("34".to_string()), Some("67".to_string())),
1570                (Some("67".to_string()), None),
1571            ]
1572        );
1573    }
1574
1575    #[mz_ore::test]
1576    fn test_single_column_pk() {
1577        use mz_mysql_util::MySqlKeyDesc;
1578        use mz_repr::SqlColumnType;
1579
1580        let col = |name: &str, ty: SqlScalarType| MySqlColumnDesc {
1581            name: name.to_string(),
1582            column_type: Some(SqlColumnType {
1583                scalar_type: ty,
1584                nullable: false,
1585            }),
1586            meta: None,
1587        };
1588        let pk = |cols: &[&str]| {
1589            BTreeSet::from([MySqlKeyDesc {
1590                name: "PRIMARY".to_string(),
1591                is_primary: true,
1592                columns: cols.iter().map(|c| c.to_string()).collect(),
1593            }])
1594        };
1595        let desc = |columns, keys| MySqlTableDesc {
1596            schema_name: "s".to_string(),
1597            name: "t".to_string(),
1598            columns,
1599            keys,
1600        };
1601
1602        // Single-column PK: returns the raw column name and its type.
1603        let (name, ty) = try_extract_single_column_pk(&desc(
1604            vec![col("id", SqlScalarType::Char { length: None })],
1605            pk(&["id"]),
1606        ))
1607        .expect("single-column pk");
1608        assert_eq!(name, "id");
1609        assert!(matches!(ty, SqlScalarType::Char { .. }));
1610
1611        let (name, ty) =
1612            try_extract_single_column_pk(&desc(vec![col("id", SqlScalarType::Bytes)], pk(&["id"])))
1613                .expect("single-column pk");
1614        assert_eq!(name, "id");
1615        assert!(matches!(ty, SqlScalarType::Bytes));
1616
1617        // Composite PK → not a single column, fall back.
1618        assert!(
1619            try_extract_single_column_pk(&desc(
1620                vec![
1621                    col("a", SqlScalarType::Char { length: None }),
1622                    col("b", SqlScalarType::Int64),
1623                ],
1624                pk(&["a", "b"]),
1625            ))
1626            .is_none()
1627        );
1628
1629        // No primary key → fall back.
1630        assert!(
1631            try_extract_single_column_pk(&desc(
1632                vec![col("id", SqlScalarType::Int64)],
1633                BTreeSet::default()
1634            ))
1635            .is_none()
1636        );
1637    }
1638}