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::errors::DataflowError;
128use mz_storage_types::sources::MySqlSourceConnection;
129use mz_storage_types::sources::mysql::{GtidPartition, gtid_set_frontier};
130use mz_timely_util::antichain::AntichainExt;
131use mz_timely_util::builder_async::{
132    Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
133};
134use mz_timely_util::containers::stack::FueledBuilder;
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::{FuelSize, 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    StackedCollection<'scope, GtidPartition, (usize, 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    let (raw_handle, raw_data) = builder.new_output::<FueledBuilder<_>>();
706    let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
707    // Captures DefiniteErrors that affect the entire source, including all outputs
708    let (definite_error_handle, definite_errors) =
709        builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
710    let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
711
712    // This operator needs to broadcast data to itself in order to synchronize the transaction
713    // snapshot. However, none of the feedback capabilities result in output messages and for the
714    // feedback edge specifically having a default connection would result in a loop.
715    let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
716
717    // The snapshot info must be sent to all workers, so we broadcast the feedback connection
718    snapshot.broadcast().connect_loop(feedback_handle);
719
720    let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
721
722    // A global view of all outputs that will be snapshot by all workers.
723    let mut all_outputs = vec![];
724    // The table infos to snapshot. Every worker holds all of them, since parallel
725    // PK-range reads split each table across workers.
726    let mut reader_snapshot_table_info = BTreeMap::new();
727    // Maps MySQL table name to export `SourceStatistics`. Same info exists in reader_snapshot_table_info,
728    // but this avoids having to iterate + map each time the statistics are needed.
729    let mut export_statistics = BTreeMap::new();
730    for output in source_outputs.into_iter() {
731        // Determine which outputs need to be snapshot and which already have been.
732        if *output.resume_upper != [GtidPartition::minimum()] {
733            // Already has been snapshotted.
734            continue;
735        }
736        all_outputs.push(output.output_index);
737        let export_stats = config
738            .statistics
739            .get(&output.export_id)
740            .expect("statistics have been intialized")
741            .clone();
742        export_statistics
743            .entry(output.table_name.clone())
744            .or_insert_with(Vec::new)
745            .push(export_stats);
746
747        reader_snapshot_table_info
748            .entry(output.table_name.clone())
749            .or_insert_with(Vec::new)
750            .push(output);
751    }
752
753    let (button, transient_errors): (_, StreamVec<'scope, GtidPartition, Rc<TransientError>>) =
754        builder.build_fallible(move |caps| {
755            let busy_signal = Arc::clone(&config.busy_signal);
756            Box::pin(SignaledFuture::new(busy_signal, async move {
757                let [
758                    data_cap_set,
759                    rewind_cap_set,
760                    definite_error_cap_set,
761                    snapshot_cap_set,
762                ]: &mut [_; 4] = caps.try_into().unwrap();
763
764                let id = config.id;
765                let worker_id = config.worker_id;
766
767                if !all_outputs.is_empty() {
768                    // A worker *must* emit a count even if not responsible for snapshotting a table
769                    // as statistic summarization will return null if any worker hasn't set a value.
770                    // This will also reset snapshot stats for any exports not snapshotting.
771                    for statistics in config.statistics.values() {
772                        statistics.set_snapshot_records_known(0);
773                        statistics.set_snapshot_records_staged(0);
774                    }
775                }
776
777                // If this worker has no tables to snapshot then there is nothing to do.
778                if reader_snapshot_table_info.is_empty() {
779                    trace!(%id, "timely-{worker_id} initializing table reader \
780                                 with no tables to snapshot, exiting");
781                    return Ok(());
782                } else {
783                    trace!(%id, "timely-{worker_id} initializing table reader \
784                                 with {} tables to snapshot",
785                           reader_snapshot_table_info.len());
786                }
787
788                let connection_config = connection
789                    .connection
790                    .config(
791                        &config.config.connection_context.secrets_reader,
792                        &config.config,
793                        InTask::Yes,
794                    )
795                    .await?;
796                let task_name = format!("timely-{worker_id} MySQL snapshotter");
797
798                // Per-table row counts, computed once during PK sampling and reused
799                // by the leader to publish the snapshot size gauge.
800                let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
801
802                let mut conn = connection_config
803                    .connect(
804                        &task_name,
805                        &config.config.connection_context.ssh_tunnel_manager,
806                    )
807                    .await?;
808
809                // Verify the MySQL system settings are correct for consistent row-based replication using GTIDs
810                match validate_mysql_repl_settings(&mut conn).await {
811                    Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
812                        return Ok(return_definite_error(
813                            DefiniteError::ServerConfigurationError(err.to_string()),
814                            &all_outputs,
815                            &raw_handle,
816                            data_cap_set,
817                            &definite_error_handle,
818                            definite_error_cap_set,
819                        )
820                        .await);
821                    }
822                    Err(err) => Err(err)?,
823                    Ok(()) => (),
824                };
825
826                if let Some(timeout) = config
827                    .config
828                    .parameters
829                    .mysql_source_timeouts
830                    .snapshot_wait_timeout
831                {
832                    set_wait_timeout(&mut *conn, timeout).await?;
833                }
834
835                let mut lock_conn = if is_snapshot_leader {
836                    match lock_and_prepare_snapshot(
837                        &config,
838                        &connection_config,
839                        &task_name,
840                        &reader_snapshot_table_info,
841                        &metrics,
842                    )
843                    .await
844                    {
845                        Ok((info, counts, conn)) => {
846                            snapshot_counts = counts;
847                            trace!(%id, "timely-{worker_id} broadcasting snapshot info: {info:?}");
848                            snapshot_handle.give(&snapshot_cap_set[0], Some(info));
849                            Some(conn)
850                        }
851                        Err(err) => {
852                            // Broadcast the failure sentinel so non-leaders exit cleanly instead
853                            // of deadlocking on the feedback loop.
854                            snapshot_handle.give(&snapshot_cap_set[0], None);
855                            return Err(err);
856                        }
857                    }
858                } else {
859                    None
860                };
861
862                // Receive the leader's broadcast: `Some` on success, `None` on leader failure.
863                let snapshot_info: Option<SnapshotInfo> = loop {
864                    match snapshot_input.next().await {
865                        Some(AsyncEvent::Data(_, mut data)) => {
866                            if let Some(msg) = data.pop() {
867                                break msg;
868                            }
869                        }
870                        Some(AsyncEvent::Progress(_)) => continue,
871                        // Feedback closed without data: the leader failed and already
872                        // propagated the error.
873                        None => break None,
874                    }
875                };
876                let snapshot_info = match snapshot_info {
877                    Some(info) => info,
878                    None => return Ok(()),
879                };
880
881                let errored: BTreeMap<usize, DefiniteError> =
882                    snapshot_info.errored_outputs.iter().cloned().collect();
883                let errored_outputs: Vec<_> = reader_snapshot_table_info
884                    .values()
885                    .flatten()
886                    .filter_map(|output| {
887                        errored.get(&output.output_index).map(|err| (output, err))
888                    })
889                    .collect();
890                let mut removed_outputs = BTreeSet::new();
891                for (output, err) in errored_outputs {
892                    removed_outputs.insert(output.output_index);
893                    // Only the responsible worker publishes any error,
894                    // so it lands once instead of once per worker reading the table.
895                    if !config.responsible_for(&output.table_name) {
896                        continue;
897                    }
898                    let update = (
899                        (output.output_index, Err(err.clone().into())),
900                        GtidPartition::minimum(),
901                        Diff::ONE,
902                    );
903                    let size = update.fuel_size();
904                    raw_handle.give_fueled(&data_cap_set[0], update, size).await;
905                    tracing::warn!(%id, "timely-{worker_id} stopping snapshot of output {output:?} \
906                                due to schema mismatch");
907                }
908                for (_, outputs) in reader_snapshot_table_info.iter_mut() {
909                    outputs.retain(|output| !removed_outputs.contains(&output.output_index));
910                }
911                reader_snapshot_table_info.retain(|_, outputs| !outputs.is_empty());
912
913                let snapshot_gtid_frontier = match gtid_set_frontier(&snapshot_info.gtid_set) {
914                    Ok(frontier) => frontier,
915                    Err(err) => {
916                        // If we received a GTID Set with non-consecutive intervals this breaks all
917                        // our assumptions, so there is nothing else we can do.
918                        return Ok(return_definite_error(
919                            DefiniteError::UnsupportedGtidState(err.to_string()),
920                            &all_outputs,
921                            &raw_handle,
922                            data_cap_set,
923                            &definite_error_handle,
924                            definite_error_cap_set,
925                        )
926                        .await);
927                    }
928                };
929
930                trace!(%id, "timely-{worker_id} received snapshot info at: {}",
931                       snapshot_gtid_frontier.pretty());
932
933                let table_ranges = plan_worker_reads(
934                    &config,
935                    &reader_snapshot_table_info,
936                    &snapshot_info.pk_bounds,
937                );
938                let has_work = !table_ranges.is_empty();
939
940                // Returning will release the snapshot capabilities unblocking the leader from dropping the lock connection.
941                if !has_work && !is_snapshot_leader {
942                    trace!(%id, "timely-{worker_id} has no tables to snapshot.");
943                    return Ok(());
944                }
945
946                trace!(%id, "timely-{worker_id} starting transaction with \
947                             consistent snapshot at: {}", snapshot_gtid_frontier.pretty());
948
949                // Start a transaction with REPEATABLE READ and 'CONSISTENT SNAPSHOT' semantics
950                // so we can read a consistent snapshot of the table at the specific GTID we read.
951                let mut tx_opts = TxOpts::default();
952                tx_opts
953                    .with_isolation_level(IsolationLevel::RepeatableRead)
954                    .with_consistent_snapshot(true)
955                    .with_readonly(true);
956                let mut tx = conn.start_transaction(tx_opts).await?;
957                // Set the session time zone to UTC so that we can read TIMESTAMP columns as UTC
958                // From https://dev.mysql.com/doc/refman/8.0/en/datetime.html: "MySQL converts TIMESTAMP values
959                // from the current time zone to UTC for storage, and back from UTC to the current time zone
960                // for retrieval. (This does not occur for other types such as DATETIME.)"
961                #[allow(clippy::disallowed_methods)] // static SQL string
962                tx.query_drop("set @@session.time_zone = '+00:00'").await?;
963
964                // Configure query execution time based on param. We want to be able to
965                // override the server value here in case it's set too low,
966                // respective to the size of the data we need to copy.
967                if let Some(timeout) = config
968                    .config
969                    .parameters
970                    .mysql_source_timeouts
971                    .snapshot_max_execution_time
972                {
973                    // Interpolating an integer millis value; not parameterizable in MySQL `SET`.
974                    #[allow(clippy::disallowed_methods)]
975                    tx.query_drop(format!(
976                        "SET @@session.max_execution_time = {}",
977                        timeout.as_millis()
978                    ))
979                    .await?;
980                }
981
982                // Signal readiness by dropping the snapshot capability, then the leader
983                // waits for every worker to signal before unlocking.
984                *snapshot_cap_set = CapabilitySet::new();
985                if is_snapshot_leader {
986                    while snapshot_input.next().await.is_some() {}
987                    if let Some(mut lc) = lock_conn.take() {
988                        #[allow(clippy::disallowed_methods)] // static SQL string
989                        lc.query_drop("UNLOCK TABLES").await?;
990                        lc.disconnect().await?;
991                    }
992                }
993                drop(lock_conn);
994
995                trace!(%id, "timely-{worker_id} started transaction (has_work={has_work}, is_snapshot_leader={is_snapshot_leader})");
996
997                // Verify the schemas of the tables we are snapshotting
998                let errored_outputs = verify_schemas(
999                    &mut tx,
1000                    reader_snapshot_table_info
1001                        .iter()
1002                        .filter(|(t, _)| table_ranges.contains_key(t))
1003                        .map(|(k, v)| (k, v.as_slice()))
1004                        .collect(),
1005                )
1006                .await?;
1007                if let Some((output, err)) = errored_outputs.into_iter().next() {
1008                    return Err(TransientError::Generic(anyhow::anyhow!(
1009                        "schema of {} changed during snapshot setup: {err}",
1010                        output.table_name
1011                    )));
1012                }
1013                verify_pk_bounds_monotonic(
1014                    &mut tx,
1015                    &reader_snapshot_table_info,
1016                    &table_ranges,
1017                    &snapshot_info.pk_bounds,
1018                )
1019                .await?;
1020
1021                // Only the leader publishes the full snapshot size, so the summed
1022                // worker-local gauges reflect the upstream total without double-counting.
1023                // The counts were computed once during PK sampling and are reused here.
1024                if is_snapshot_leader {
1025                    publish_snapshot_size(
1026                        &snapshot_counts,
1027                        &reader_snapshot_table_info,
1028                        &export_statistics,
1029                    );
1030                }
1031
1032                // This worker has nothing else to do
1033                if reader_snapshot_table_info.is_empty() {
1034                    return Ok(());
1035                }
1036
1037                // Read the snapshot data from the tables
1038                let mut final_row = Row::default();
1039
1040                let mut snapshot_staged_total = 0;
1041                for (table, outputs) in &reader_snapshot_table_info {
1042                    let pk_range = match table_ranges.get(table) {
1043                        Some(ReadPlan::Range(range)) => Some(range),
1044                        Some(ReadPlan::WholeTable) => None,
1045                        // This worker has no work for this table.
1046                        None => continue,
1047                    };
1048
1049                    let mut snapshot_staged = 0;
1050                    let (query, params) = build_snapshot_query(outputs, pk_range);
1051                    trace!(%id, "timely-{worker_id} reading snapshot query='{}'", query);
1052                    let mut results = tx.exec_stream(query, params).await?;
1053                    while let Some(row) = results.try_next().await? {
1054                        let row: MySqlRow = row;
1055                        snapshot_staged += 1;
1056                        for (output, row_val) in outputs.iter().repeat_clone(row) {
1057                            // We don't need to verify if binlog_row_metadata matches the expected when snapshotting as
1058                            // the snapshot query always returns rows with full metadata. If the output is configured
1059                            // with binlog_full_metadata = false, then we will just ignore the metadata when decoding.
1060                            let event = match pack_mysql_row(
1061                                &mut final_row,
1062                                row_val,
1063                                &output.desc,
1064                                None,
1065                                output.binlog_full_metadata,
1066                            ) {
1067                                Ok(row) => Ok(SourceMessage {
1068                                    key: Row::default(),
1069                                    value: row,
1070                                    metadata: Row::default(),
1071                                }),
1072                                // Produce a DefiniteError in the stream for any rows that fail to decode
1073                                Err(err @ MySqlError::ValueDecodeError { .. }) => {
1074                                    Err(DataflowError::from(DefiniteError::ValueDecodeError(
1075                                        err.to_string(),
1076                                    )))
1077                                }
1078                                Err(err) => Err(err)?,
1079                            };
1080                            let update = (
1081                                (output.output_index, event),
1082                                GtidPartition::minimum(),
1083                                Diff::ONE,
1084                            );
1085                            let size = update.fuel_size();
1086                            raw_handle.give_fueled(&data_cap_set[0], update, size).await;
1087                        }
1088                        // This overcounting maintains existing behavior but will be removed once readers no longer rely on the value.
1089                        snapshot_staged_total += u64::cast_from(outputs.len());
1090                        if snapshot_staged_total % 1000 == 0 {
1091                            for statistics in export_statistics.get(table).unwrap() {
1092                                statistics.set_snapshot_records_staged(snapshot_staged);
1093                            }
1094                        }
1095                    }
1096                    for statistics in export_statistics.get(table).unwrap() {
1097                        statistics.set_snapshot_records_staged(snapshot_staged);
1098                    }
1099                    trace!(%id, "timely-{worker_id} snapshotted {} records from \
1100                                 table '{table}'", snapshot_staged * u64::cast_from(outputs.len()));
1101                }
1102
1103                // We are done with the snapshot so now we will emit rewind requests. It is
1104                // important that this happens after the snapshot has finished because this is what
1105                // unblocks the replication operator and we want this to happen serially. It might
1106                // seem like a good idea to read the replication stream concurrently with the
1107                // snapshot but it actually leads to a lot of data being staged for the future,
1108                // which needlesly consumed memory in the cluster.
1109                for (table, outputs) in &reader_snapshot_table_info {
1110                    if !config.responsible_for(table) {
1111                        continue;
1112                    }
1113                    for output in outputs {
1114                        trace!(%id, "timely-{worker_id} producing rewind request for {table}\
1115                                     output {}", output.output_index);
1116                        let req = RewindRequest {
1117                            output_index: output.output_index,
1118                            snapshot_upper: snapshot_gtid_frontier.clone(),
1119                        };
1120                        rewinds_handle.give(&rewind_cap_set[0], req);
1121                    }
1122                }
1123                *rewind_cap_set = CapabilitySet::new();
1124
1125                Ok(())
1126            }))
1127        });
1128
1129    // TODO: Split row decoding into a separate operator that can be distributed across all workers
1130
1131    let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
1132
1133    (
1134        raw_data.as_collection(),
1135        rewinds,
1136        errors,
1137        button.press_on_drop(),
1138    )
1139}
1140
1141/// Publish the snapshot size to each table's statistics gauges, using the counts
1142/// computed once during PK sampling. Called leader-only so the summed worker-local gauges
1143/// reflect the upstream total without double-counting.
1144fn publish_snapshot_size(
1145    counts: &BTreeMap<MySqlTableName, u64>,
1146    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
1147    export_statistics: &BTreeMap<MySqlTableName, Vec<SourceStatistics>>,
1148) {
1149    for name in tables.keys() {
1150        let count = counts.get(name).copied().unwrap_or(0);
1151        let stats = export_statistics
1152            .get(name)
1153            .expect("statistics are initialized for each output");
1154        for export_stat in stats {
1155            export_stat.set_snapshot_records_known(count);
1156            export_stat.set_snapshot_records_staged(0);
1157        }
1158    }
1159}
1160
1161/// Sets the session wait_timeout so a lowered global value cannot reap the
1162/// connection while it sits idle during snapshot setup.
1163async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1164where
1165    Q: Queryable,
1166{
1167    // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1168    #[allow(clippy::disallowed_methods)]
1169    conn.query_drop(format!(
1170        "SET @@session.wait_timeout = {}",
1171        timeout.as_secs()
1172    ))
1173    .await
1174}
1175async fn lock_tables_and_read_gtid_set(
1176    lock_conn: &mut MySqlConn,
1177    lock_clauses: &str,
1178    lock_wait_timeout: Option<Duration>,
1179) -> Result<String, TransientError> {
1180    if let Some(timeout) = lock_wait_timeout {
1181        // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1182        #[allow(clippy::disallowed_methods)]
1183        lock_conn
1184            .query_drop(format!(
1185                "SET @@session.lock_wait_timeout = {}",
1186                timeout.as_secs()
1187            ))
1188            .await?;
1189    }
1190
1191    // `lock_clauses` is built from `MySqlTableName::Display`, which escapes both
1192    // schema and table via `quote_identifier`.
1193    if !lock_clauses.is_empty() {
1194        #[allow(clippy::disallowed_methods)]
1195        lock_conn
1196            .query_drop(format!("LOCK TABLES {lock_clauses}"))
1197            .await?;
1198    }
1199
1200    let snapshot_gtid_set = query_sys_var(lock_conn, "global.gtid_executed").await?;
1201    Ok(snapshot_gtid_set)
1202}
1203
1204/// Builds the SQL query to be used for creating the snapshot using the first entry in outputs.
1205///
1206/// Expect `outputs` to contain entries for a single table, and to have at least 1 entry.
1207/// Expect that each MySqlTableDesc entry contains all columns described in information_schema.columns.
1208///
1209/// When `pk_range` is provided, a WHERE clause is appended to filter rows by PK range.
1210#[must_use]
1211fn build_snapshot_query(
1212    outputs: &[SourceOutputInfo],
1213    pk_range: Option<&PkRange>,
1214) -> (String, Vec<Value>) {
1215    let info = outputs.first().expect("MySQL table info");
1216    for output in &outputs[1..] {
1217        // the columns may be decoded based on position, and different outputs may replicate
1218        // different columns, so we need to ensure that all columns are accounted for.
1219        assert!(
1220            info.desc.columns.len() == output.desc.columns.len(),
1221            "Mismatch in table descriptions for {}",
1222            info.table_name
1223        );
1224    }
1225    let columns = info
1226        .desc
1227        .columns
1228        .iter()
1229        .map(|col| quote_identifier(&col.name))
1230        .join(", ");
1231    let mut query = format!("SELECT {} FROM {}", columns, info.table_name);
1232    let mut params: Vec<Value> = vec![];
1233    if let Some(range) = pk_range {
1234        // Half-open range on the PK column. The first/last partition omits its
1235        // open bound.
1236        let col = &range.pk_col;
1237        if let Some(lower) = &range.lower {
1238            query.push_str(&format!(" WHERE {col} >= ?"));
1239            params.push(lower.as_str().into());
1240        }
1241        if let Some(upper) = &range.upper {
1242            let kw = if range.lower.is_some() {
1243                "AND"
1244            } else {
1245                "WHERE"
1246            };
1247            query.push_str(&format!(" {kw} {col} < ?"));
1248            params.push(upper.as_str().into());
1249        }
1250    }
1251    (query, params)
1252}
1253
1254#[derive(Default)]
1255struct TableStatistics {
1256    count_latency: f64,
1257    count: u64,
1258}
1259
1260/// Row count for the snapshot size gauge. Tables whose optimizer row estimate exceeds
1261/// `exact_count_max_rows` report the estimate directly, everything else is counted
1262/// exactly with `COUNT(*)`. The gauge only drives progress reporting, and an estimate is
1263/// a fair trade for skipping an O(rows) index walk on a large table.
1264async fn collect_table_statistics<Q>(
1265    conn: &mut Q,
1266    table: &MySqlTableName,
1267    exact_count_max_rows: u64,
1268) -> Result<TableStatistics, TransientError>
1269where
1270    Q: Queryable,
1271{
1272    let mut stats = TableStatistics::default();
1273
1274    // The optimizer's row estimate for the table. InnoDB keeps it roughly current
1275    // (within the churn since the last stats recalculation), but it can be
1276    // stale-at-zero, in which case we fall through to the exact count. We don't expect
1277    // it to be null, but also fall back to count(*) in that case.
1278    let estimate: Option<Option<u64>> = conn
1279        .exec_first(
1280            "SELECT table_rows FROM information_schema.tables \
1281             WHERE table_schema = ? AND table_name = ?",
1282            (&table.0, &table.1),
1283        )
1284        .wall_time()
1285        .set_at(&mut stats.count_latency)
1286        .await?;
1287    match estimate.flatten() {
1288        Some(estimate) if estimate > exact_count_max_rows => {
1289            stats.count = estimate;
1290        }
1291        _ => {
1292            // `MySqlTableName::Display` escapes both identifier components via
1293            // `quote_identifier`, so this interpolation is safe; not parameterizable.
1294            #[allow(clippy::disallowed_methods)]
1295            let count_row: Option<u64> = conn
1296                .query_first(format!("SELECT COUNT(*) FROM {}", table))
1297                .wall_time()
1298                .set_at(&mut stats.count_latency)
1299                .await?;
1300            // `COUNT(*)` returns exactly one row, so `None` should be impossible.
1301            // Default to 0 defensively rather than failing the snapshot on a protocol
1302            // quirk.
1303            stats.count = count_row.unwrap_or(0);
1304        }
1305    }
1306
1307    Ok(stats)
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313    use mz_mysql_util::{MySqlColumnDesc, MySqlTableDesc};
1314    use timely::progress::Antichain;
1315
1316    #[mz_ore::test]
1317    fn snapshot_query_duplicate_table() {
1318        let schema_name = "myschema".to_string();
1319        let table_name = "mytable".to_string();
1320        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1321        let columns = ["c1", "c2", "c3"]
1322            .iter()
1323            .map(|col| MySqlColumnDesc {
1324                name: col.to_string(),
1325                column_type: None,
1326                meta: None,
1327            })
1328            .collect::<Vec<_>>();
1329        let desc = MySqlTableDesc {
1330            schema_name: schema_name.clone(),
1331            name: table_name.clone(),
1332            columns,
1333            keys: BTreeSet::default(),
1334        };
1335        let info = SourceOutputInfo {
1336            output_index: 1, // ignored
1337            table_name: table.clone(),
1338            desc,
1339            text_columns: vec![],
1340            exclude_columns: vec![],
1341            initial_gtid_set: Antichain::default(),
1342            resume_upper: Antichain::default(),
1343            export_id: mz_repr::GlobalId::User(1),
1344            binlog_full_metadata: false,
1345        };
1346        let (query, _) = build_snapshot_query(&[info.clone(), info], None);
1347        assert_eq!(
1348            format!(
1349                "SELECT `c1`, `c2`, `c3` FROM `{}`.`{}`",
1350                schema_name, table_name
1351            ),
1352            query
1353        );
1354    }
1355
1356    #[mz_ore::test]
1357    fn snapshot_query_with_pk_range() {
1358        let schema_name = "myschema".to_string();
1359        let table_name = "mytable".to_string();
1360        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1361        let columns = ["id", "name"]
1362            .iter()
1363            .map(|col| MySqlColumnDesc {
1364                name: col.to_string(),
1365                column_type: None,
1366                meta: None,
1367            })
1368            .collect::<Vec<_>>();
1369        let desc = MySqlTableDesc {
1370            schema_name: schema_name.clone(),
1371            name: table_name.clone(),
1372            columns,
1373            keys: BTreeSet::default(),
1374        };
1375        let info = SourceOutputInfo {
1376            output_index: 1,
1377            table_name: table.clone(),
1378            desc,
1379            text_columns: vec![],
1380            exclude_columns: vec![],
1381            initial_gtid_set: Antichain::default(),
1382            resume_upper: Antichain::default(),
1383            export_id: mz_repr::GlobalId::User(1),
1384            binlog_full_metadata: false,
1385        };
1386
1387        // Middle worker: both bounds.
1388        let range = PkRange {
1389            pk_col: "`id`".to_string(),
1390            lower: Some("100".to_string()),
1391            upper: Some("200".to_string()),
1392        };
1393        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1394        assert_eq!(
1395            format!(
1396                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ? AND `id` < ?",
1397                schema_name, table_name
1398            ),
1399            query
1400        );
1401        assert_eq!(params, vec![Value::from("100"), Value::from("200")]);
1402
1403        // First worker: open start.
1404        let range = PkRange {
1405            pk_col: "`id`".to_string(),
1406            lower: None,
1407            upper: Some("200".to_string()),
1408        };
1409        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1410        assert_eq!(
1411            format!(
1412                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` < ?",
1413                schema_name, table_name
1414            ),
1415            query
1416        );
1417        assert_eq!(params, vec![Value::from("200")]);
1418
1419        // Last worker: open end.
1420        let range = PkRange {
1421            pk_col: "`id`".to_string(),
1422            lower: Some("200".to_string()),
1423            upper: None,
1424        };
1425        let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1426        assert_eq!(
1427            format!(
1428                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ?",
1429                schema_name, table_name
1430            ),
1431            query
1432        );
1433        assert_eq!(params, vec![Value::from("200")]);
1434    }
1435
1436    #[mz_ore::test]
1437    fn test_worker_pk_range() {
1438        // Two partitions, boundary at 51. Owner 0 is the identity mapping
1439        // (partition == worker_id).
1440        let splits = PkBoundaries {
1441            pk_col: "`id`".to_string(),
1442            boundaries: vec!["51".to_string()],
1443        };
1444        let r0 = worker_pk_range(&splits, 0, 0, 4).expect("worker 0");
1445        assert_eq!(r0.pk_col, "`id`");
1446        assert_eq!(r0.lower, None); // open start
1447        assert_eq!(r0.upper.as_deref(), Some("51"));
1448        let r1 = worker_pk_range(&splits, 1, 0, 4).expect("worker 1");
1449        assert_eq!(r1.lower.as_deref(), Some("51"));
1450        assert_eq!(r1.upper, None); // open end
1451        // A surplus worker beyond the partition count has no work.
1452        assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1453
1454        // Three partitions: the middle worker has both bounds.
1455        let splits = PkBoundaries {
1456            pk_col: "`id`".to_string(),
1457            boundaries: vec!["34".to_string(), "67".to_string()],
1458        };
1459        let r1 = worker_pk_range(&splits, 1, 0, 3).expect("worker 1");
1460        assert_eq!(r1.lower.as_deref(), Some("34"));
1461        assert_eq!(r1.upper.as_deref(), Some("67"));
1462
1463        // Offsetting by the owner still assigns every worker a distinct partition,
1464        // and the owner reads the open-started first partition.
1465        let owner = 2;
1466        let owned = worker_pk_range(&splits, owner, owner, 3).expect("owner has work");
1467        assert_eq!(owned.lower, None);
1468        let mut ranges: Vec<_> = (0..3)
1469            .map(|w| {
1470                let r = worker_pk_range(&splits, w, owner, 3).expect("worker has work");
1471                (r.lower, r.upper)
1472            })
1473            .collect();
1474        ranges.sort();
1475        assert_eq!(
1476            ranges,
1477            vec![
1478                (None, Some("34".to_string())),
1479                (Some("34".to_string()), Some("67".to_string())),
1480                (Some("67".to_string()), None),
1481            ]
1482        );
1483    }
1484
1485    #[mz_ore::test]
1486    fn test_single_column_pk() {
1487        use mz_mysql_util::MySqlKeyDesc;
1488        use mz_repr::SqlColumnType;
1489
1490        let col = |name: &str, ty: SqlScalarType| MySqlColumnDesc {
1491            name: name.to_string(),
1492            column_type: Some(SqlColumnType {
1493                scalar_type: ty,
1494                nullable: false,
1495            }),
1496            meta: None,
1497        };
1498        let pk = |cols: &[&str]| {
1499            BTreeSet::from([MySqlKeyDesc {
1500                name: "PRIMARY".to_string(),
1501                is_primary: true,
1502                columns: cols.iter().map(|c| c.to_string()).collect(),
1503            }])
1504        };
1505        let desc = |columns, keys| MySqlTableDesc {
1506            schema_name: "s".to_string(),
1507            name: "t".to_string(),
1508            columns,
1509            keys,
1510        };
1511
1512        // Single-column PK: returns the raw column name and its type.
1513        let (name, ty) = try_extract_single_column_pk(&desc(
1514            vec![col("id", SqlScalarType::Char { length: None })],
1515            pk(&["id"]),
1516        ))
1517        .expect("single-column pk");
1518        assert_eq!(name, "id");
1519        assert!(matches!(ty, SqlScalarType::Char { .. }));
1520
1521        let (name, ty) =
1522            try_extract_single_column_pk(&desc(vec![col("id", SqlScalarType::Bytes)], pk(&["id"])))
1523                .expect("single-column pk");
1524        assert_eq!(name, "id");
1525        assert!(matches!(ty, SqlScalarType::Bytes));
1526
1527        // Composite PK → not a single column, fall back.
1528        assert!(
1529            try_extract_single_column_pk(&desc(
1530                vec![
1531                    col("a", SqlScalarType::Char { length: None }),
1532                    col("b", SqlScalarType::Int64),
1533                ],
1534                pk(&["a", "b"]),
1535            ))
1536            .is_none()
1537        );
1538
1539        // No primary key → fall back.
1540        assert!(
1541            try_extract_single_column_pk(&desc(
1542                vec![col("id", SqlScalarType::Int64)],
1543                BTreeSet::default()
1544            ))
1545            .is_none()
1546        );
1547    }
1548}