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, TxOpts};
119use mz_mysql_util::{MySqlConn, MySqlError, pack_mysql_row, query_sys_var, quote_identifier};
120use mz_ore::cast::CastFrom;
121use mz_ore::future::InTask;
122use mz_ore::iter::IteratorExt;
123use mz_ore::metrics::MetricsFutureExt;
124use mz_repr::{Diff, Row, SqlScalarType};
125use mz_storage_types::errors::DataflowError;
126use mz_storage_types::sources::MySqlSourceConnection;
127use mz_storage_types::sources::mysql::{GtidPartition, gtid_set_frontier};
128use mz_timely_util::antichain::AntichainExt;
129use mz_timely_util::builder_async::{
130    Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
131};
132use mz_timely_util::containers::stack::FueledBuilder;
133use timely::container::CapacityContainerBuilder;
134use timely::dataflow::channels::pact::Pipeline;
135use timely::dataflow::operators::core::Map;
136use timely::dataflow::operators::vec::Broadcast;
137use timely::dataflow::operators::{CapabilitySet, Concat, ConnectLoop, Feedback};
138use timely::dataflow::{Scope, StreamVec};
139use timely::progress::Timestamp;
140use tracing::trace;
141
142use crate::metrics::source::mysql::MySqlSnapshotMetrics;
143use crate::source::RawSourceCreationConfig;
144use crate::source::types::{FuelSize, SignaledFuture, SourceMessage, StackedCollection};
145use crate::statistics::SourceStatistics;
146
147use super::schemas::verify_schemas;
148use super::{
149    DefiniteError, MySqlTableName, ReplicationError, RewindRequest, SourceOutputInfo,
150    TransientError, return_definite_error, validate_mysql_repl_settings,
151};
152
153/// The raw (unquoted) name and scalar type of a table's primary key, when it is a
154/// single column. Callers quote the name for SQL predicates and use the raw name for
155/// `information_schema` lookups.
156fn try_extract_single_column_pk(
157    desc: &mz_mysql_util::MySqlTableDesc,
158) -> Option<(String, SqlScalarType)> {
159    let pk = desc.keys.iter().find(|k| k.is_primary)?;
160    let [name] = &pk.columns[..] else {
161        return None;
162    };
163    let col = desc.columns.iter().find(|c| &c.name == name)?;
164    if col.meta.is_some() {
165        return None;
166    }
167    let scalar_type = col.column_type.as_ref()?.scalar_type.clone();
168    Some((name.clone(), scalar_type))
169}
170
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
172struct PkBoundaries {
173    pk_col: String,
174    // Ordered primary key values that partition the table space.
175    boundaries: Vec<String>,
176}
177
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
179struct SnapshotInfo {
180    gtid_set: String,
181    /// PK splits per table. None = no suitable PK, use single-worker fallback.
182    pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>>,
183    errored_outputs: Vec<(usize, DefiniteError)>,
184}
185
186struct PkRange {
187    /// Quoted PK column, e.g. `` `id` ``.
188    pk_col: String,
189    /// Inclusive lower bound literal, or `None` for the first partition (open start).
190    lower: Option<String>,
191    /// Exclusive upper bound literal, or `None` for the last partition (open end).
192    upper: Option<String>,
193}
194
195/// What a worker does for one table during the snapshot.
196enum ReadPlan {
197    /// Partitioned table: read this worker's assigned PK range.
198    Range(PkRange),
199    /// Unpartitioned table: this worker is responsible for it and reads it whole.
200    WholeTable,
201}
202
203/// This worker's PK range, or `None` if it owns no partition. Rotating the partition
204/// by the table's `owner` keeps the open-ended ranges from always landing on the same workers.
205fn worker_pk_range(
206    splits: &PkBoundaries,
207    worker_id: usize,
208    owner_worker_id: usize,
209    worker_count: usize,
210) -> Option<PkRange> {
211    let partition = (worker_id + worker_count - owner_worker_id) % worker_count;
212    let partitions = splits.boundaries.len() + 1;
213    if partition >= partitions {
214        return None;
215    }
216    Some(PkRange {
217        pk_col: splits.pk_col.clone(),
218        lower: (partition > 0).then(|| splits.boundaries[partition - 1].clone()),
219        upper: (partition < partitions - 1).then(|| splits.boundaries[partition].clone()),
220    })
221}
222
223/// Walks the primary key index in steps of about `row_count / worker_count`, taking the key
224/// at each step's `OFFSET`. The per-step OFFSET scans sum to a full index pass, so this
225/// function has a time complexity of O(row_count). Worker count is small, so the OFFSET
226/// scans dominate the runtime. `row_count` can be an optimizer estimate for large tables,
227/// so the partitions are approximate. An overestimate walks off the end of the index and stops
228/// with fewer boundaries, resulting in some workers receiving less or no work. An underestimate
229/// leaves a larger final partition for the last worker, however both still correctly partition
230/// the table. Returns None if the primary key column type is not supported or the table is too
231/// small to split.
232async fn compute_sampled_splits<Q>(
233    conn: &mut Q,
234    table: &MySqlTableName,
235    pk_col: &(String, SqlScalarType),
236    worker_count: usize,
237    total: u64,
238) -> Result<Option<PkBoundaries>, TransientError>
239where
240    Q: Queryable,
241{
242    let (col, scalar_type) = pk_col;
243    // Render the PK column as text that sorts and compares the same way the range
244    // predicates do: `QUOTE()` under the column's collation for character types,
245    // `CAST(.. AS CHAR)` for integers. Any other type can't be split safely.
246    let (col_literal, integer_path) = match scalar_type {
247        SqlScalarType::Int16
248        | SqlScalarType::Int32
249        | SqlScalarType::Int64
250        | SqlScalarType::UInt16
251        | SqlScalarType::UInt32
252        | SqlScalarType::UInt64 => (format!("CAST({col} AS CHAR)"), true),
253        SqlScalarType::Char { .. } | SqlScalarType::VarChar { .. } | SqlScalarType::String => {
254            (format!("QUOTE({col})"), false)
255        }
256        _ => return Ok(None),
257    };
258
259    let partitions = std::cmp::min(u64::cast_from(worker_count), total);
260    if partitions < 2 {
261        return Ok(None);
262    }
263    let chunk = total / partitions;
264
265    let mut boundaries: Vec<String> = Vec::with_capacity(usize::cast_from(partitions) - 1);
266    for _ in 1..partitions {
267        let (predicate, offset) = match boundaries.last() {
268            Some(prev) => (format!(" WHERE {col} > {prev}"), chunk - 1),
269            None => (String::new(), chunk),
270        };
271        // The identifier is quoted via `quote_identifier`, the previous boundary is
272        // itself a value MySQL rendered as a literal, `table` via Display, and the
273        // offset is an integer, so this interpolation is safe; not parameterizable.
274        #[allow(clippy::disallowed_methods)]
275        let row: Option<MySqlRow> = conn
276            .query_first(format!(
277                "SELECT {col_literal} FROM {table}{predicate} \
278                 ORDER BY {col} LIMIT 1 OFFSET {offset}"
279            ))
280            .await?;
281        // Defensive: if a concurrent write shrank the range out from under us, stop and
282        // use the boundaries found so far. Fewer partitions is still correct.
283        let Some(mut row) = row else { break };
284        // The column is CAST/QUOTE-ed to text, so it decodes as a String that is
285        // already a valid SQL literal. A decode failure (e.g. a non-UTF-8
286        // collation) means we can't safely partition: fall back.
287        match row.take_opt::<String, usize>(0) {
288            Some(Ok(lit)) if !integer_path || is_decimal_literal(&lit) => boundaries.push(lit),
289            _ => return Ok(None),
290        }
291    }
292    if boundaries.is_empty() {
293        return Ok(None);
294    }
295    Ok(Some(PkBoundaries {
296        pk_col: col.clone(),
297        boundaries,
298    }))
299}
300
301/// For every table, read the row count (exact only for small tables) and, for a
302/// supported single-column primary key, compute the PK-range split boundaries,
303/// concurrently over at most `worker_count` connections. `None` bounds means
304/// single-worker fallback for that table. The counts are reused for both the sampling
305/// stride and the snapshot size gauge. Snapshot size gauge is a metric for the snapshot
306/// size used to report how many rows we need to process. "Sampling stride" refers to
307/// the number of rows we use to page through the table to find roughly evenly spaced
308/// primary keys to use as partition boundaries.
309async fn sample_pk_bounds(
310    config: &RawSourceCreationConfig,
311    connection_config: &mz_mysql_util::Config,
312    task_name: &str,
313    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
314    metrics: &MySqlSnapshotMetrics,
315) -> Result<
316    (
317        BTreeMap<MySqlTableName, Option<PkBoundaries>>,
318        BTreeMap<MySqlTableName, u64>,
319    ),
320    TransientError,
321> {
322    let ssh_tunnel_manager = &config.config.connection_context.ssh_tunnel_manager;
323    let worker_count = config.worker_count;
324    let max_execution_time = config
325        .config
326        .parameters
327        .mysql_source_timeouts
328        .snapshot_max_execution_time;
329    // Kill switch for PK-range splitting. When disabled every table gets `None`
330    // bounds, i.e. the single-worker-per-table fallback. The counts still run,
331    // they feed the snapshot size gauge.
332    let parallelism_enabled = mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARALLELISM
333        .get(config.config.config_set());
334    let exact_count_max_rows = u64::cast_from(
335        mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS
336            .get(config.config.config_set()),
337    );
338
339    let pooled_conns: Rc<RefCell<Vec<MySqlConn>>> = Rc::new(RefCell::new(Vec::new()));
340    // Counting and boundary-sampling each walk a table's index (O(rows)), so run tables
341    // concurrently, capped at worker_count, to keep the leader's setup from scaling with
342    // table count.
343    let per_table: Vec<(MySqlTableName, u64, Option<PkBoundaries>)> = futures::stream::iter(tables)
344        .map(|(table, outputs)| {
345            let pool = Rc::clone(&pooled_conns);
346            async move {
347                // Grab the connection before the match to ensure the borrow is dropped
348                // before any awaits are called.
349                let pooled = pool.borrow_mut().pop();
350                let mut conn = match pooled {
351                    Some(conn) => conn,
352                    None => {
353                        let mut conn = connection_config
354                            .connect(task_name, ssh_tunnel_manager)
355                            .await?;
356                        if let Some(timeout) = max_execution_time {
357                            #[allow(clippy::disallowed_methods)]
358                            conn.query_drop(format!(
359                                "SET @@session.max_execution_time = {}",
360                                timeout.as_millis()
361                            ))
362                            .await?;
363                        }
364                        #[allow(clippy::disallowed_methods)]
365                        conn.query_drop("START TRANSACTION READ ONLY").await?;
366                        conn
367                    }
368                };
369                // Row count, reused for the sampling stride and the size gauge. When it
370                // is counted exactly it runs on the same `READ ONLY` transaction as the
371                // boundary walk in `compute_sampled_splits`, so both see one consistent
372                // snapshot. For large tables it is an optimizer estimate instead, which
373                // `compute_sampled_splits` tolerates.
374                let stats =
375                    collect_table_statistics(&mut *conn, table, exact_count_max_rows).await?;
376                metrics.record_table_count_latency(
377                    table.1.clone(),
378                    table.0.clone(),
379                    stats.count_latency,
380                );
381                let count = stats.count;
382                // Compute split boundaries only for a supported single-column PK.
383                let splits = match parallelism_enabled
384                    .then(|| try_extract_single_column_pk(&outputs[0].desc))
385                    .flatten()
386                {
387                    Some((raw_col, scalar_type)) => {
388                        let pk_col = (quote_identifier(&raw_col), scalar_type);
389                        compute_sampled_splits(&mut *conn, table, &pk_col, worker_count, count)
390                            .await?
391                    }
392                    None => None,
393                };
394                pool.borrow_mut().push(conn);
395                Ok::<_, TransientError>((table.clone(), count, splits))
396            }
397        })
398        // At most `worker_count` connections are checked out at once, so the pool
399        // opens at most `min(worker_count, num_tables)` in total.
400        .buffer_unordered(worker_count)
401        .try_collect()
402        .await?;
403
404    let mut pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>> = BTreeMap::new();
405    let mut counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
406    for (table, count, splits) in per_table {
407        pk_bounds.insert(table.clone(), splits);
408        counts.insert(table, count);
409    }
410
411    // Every future has completed and dropped its `Rc` clone, so `pool` is the sole
412    // owner. Release the probe connections now, ending their `READ ONLY` transactions
413    // and the shared metadata locks they hold, before the caller takes `LOCK TABLES`.
414    let probe_conns = Rc::into_inner(pooled_conns)
415        .expect("all sampling futures completed, so no Rc clones remain")
416        .into_inner();
417    for conn in probe_conns {
418        conn.disconnect().await?;
419    }
420    Ok((pk_bounds, counts))
421}
422
423/// Leader-only snapshot setup: sample PK bounds, lock the tables `READ`, and read
424/// the snapshot GTID frontier. All fallible work happens here so the caller can
425/// always broadcast a result. A dropped `snapshot_cap_set` with no broadcast
426/// deadlocks the other workers waiting on the feedback loop.
427async fn lock_and_prepare_snapshot(
428    config: &RawSourceCreationConfig,
429    connection_config: &mz_mysql_util::Config,
430    task_name: &str,
431    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
432    metrics: &MySqlSnapshotMetrics,
433) -> Result<(SnapshotInfo, BTreeMap<MySqlTableName, u64>, MySqlConn), TransientError> {
434    let mut lock_conn = connection_config
435        .connect(
436            task_name,
437            &config.config.connection_context.ssh_tunnel_manager,
438        )
439        .await?;
440
441    if let Some(timeout) = config
442        .config
443        .parameters
444        .mysql_source_timeouts
445        .snapshot_wait_timeout
446    {
447        set_wait_timeout(&mut *lock_conn, timeout).await?;
448    }
449
450    let errored_outputs = verify_output_schemas(&mut *lock_conn, tables).await?;
451    let errored: BTreeSet<usize> = errored_outputs.iter().map(|(idx, _)| *idx).collect();
452    let sample_tables: BTreeMap<MySqlTableName, Vec<SourceOutputInfo>> = tables
453        .iter()
454        .map(|(table, outputs)| {
455            let outputs = outputs
456                .iter()
457                .filter(|o| !errored.contains(&o.output_index))
458                .cloned()
459                .collect::<Vec<_>>();
460            (table.clone(), outputs)
461        })
462        .filter(|(_, outputs)| !outputs.is_empty())
463        .collect();
464
465    // Sampling is expensive, so run it before locking writes.
466    let (pk_bounds, counts) = sample_pk_bounds(
467        config,
468        connection_config,
469        task_name,
470        &sample_tables,
471        metrics,
472    )
473    .await?;
474
475    let lock_clauses = sample_tables
476        .keys()
477        .map(|t| format!("{} READ", t))
478        .collect::<Vec<String>>()
479        .join(", ");
480
481    // TODO(roshan): Insert metric for how long it took to acquire the locks
482    let snapshot_gtid_set = lock_tables_and_read_gtid_set(
483        &mut lock_conn,
484        &lock_clauses,
485        config
486            .config
487            .parameters
488            .mysql_source_timeouts
489            .snapshot_lock_wait_timeout,
490    )
491    .await?;
492
493    Ok((
494        SnapshotInfo {
495            gtid_set: snapshot_gtid_set,
496            pk_bounds,
497            errored_outputs,
498        },
499        counts,
500        lock_conn,
501    ))
502}
503
504async fn verify_output_schemas<Q>(
505    conn: &mut Q,
506    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
507) -> Result<Vec<(usize, DefiniteError)>, TransientError>
508where
509    Q: Queryable,
510{
511    let errored = verify_schemas(
512        conn,
513        tables.iter().map(|(k, v)| (k, v.as_slice())).collect(),
514    )
515    .await?;
516    Ok(errored
517        .into_iter()
518        .map(|(output, err)| (output.output_index, err))
519        .collect())
520}
521
522/// Character set and collation of `column` in `table`, or `None` if the column has no
523/// collation (numeric/temporal types sort independently of collation) or is absent.
524async fn fetch_column_collation<Q>(
525    conn: &mut Q,
526    table: &MySqlTableName,
527    column: &str,
528) -> Result<Option<(String, String)>, TransientError>
529where
530    Q: Queryable,
531{
532    let row: Option<(Option<String>, Option<String>)> = conn
533        .exec_first(
534            "SELECT character_set_name, collation_name \
535             FROM information_schema.columns \
536             WHERE table_schema = ? AND table_name = ? AND column_name = ?",
537            (&table.0, &table.1, column),
538        )
539        .await?;
540    // The two are NULL together for a non-character column.
541    Ok(row.and_then(|(charset, collation)| Some((charset?, collation?))))
542}
543
544/// Whether `boundaries` are strictly increasing under `collation`. The half-open PK
545/// ranges only partition the table without gaps or overlaps when this holds. The
546/// boundaries are already SQL literals (from `QUOTE()`); each is coerced to
547/// `charset`/`collation` so the comparison uses the same collation as the column,
548/// matching the read predicates. Fewer than two boundaries are trivially monotonic.
549async fn boundaries_strictly_monotonic<Q>(
550    conn: &mut Q,
551    boundaries: &[String],
552    charset: &str,
553    collation: &str,
554) -> Result<bool, TransientError>
555where
556    Q: Queryable,
557{
558    if boundaries.len() < 2 {
559        return Ok(true);
560    }
561    // `charset`/`collation` come from `information_schema` and can't be bound as parameters,
562    // so only interpolate the plain-identifier shape we expect. Anything else is unvalidated.
563    if !is_plain_ident(charset) || !is_plain_ident(collation) {
564        return Ok(false);
565    }
566    let terms = boundaries
567        .iter()
568        .map(|b| format!("CONVERT({b} USING {charset}) COLLATE {collation}"))
569        .collect::<Vec<_>>();
570    let predicate = terms
571        .windows(2)
572        .map(|w| format!("{} < {}", w[0], w[1]))
573        .collect::<Vec<_>>()
574        .join(" AND ");
575    // Boundaries are MySQL-rendered literals and the identifiers are validated above,
576    // so this interpolation is safe; not parameterizable.
577    #[allow(clippy::disallowed_methods)]
578    let ok: Option<i64> = conn.query_first(format!("SELECT {predicate}")).await?;
579    Ok(ok == Some(1))
580}
581
582async fn verify_pk_bounds_monotonic<Q>(
583    tx: &mut Q,
584    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
585    table_ranges: &BTreeMap<MySqlTableName, ReadPlan>,
586    pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
587) -> Result<(), TransientError>
588where
589    Q: Queryable,
590{
591    for (table, plan) in table_ranges {
592        if !matches!(plan, ReadPlan::Range(_)) {
593            continue;
594        }
595        let Some(Some(splits)) = pk_bounds.get(table) else {
596            continue;
597        };
598        let Some((raw_col, _)) = tables
599            .get(table)
600            .and_then(|outputs| try_extract_single_column_pk(&outputs[0].desc))
601        else {
602            continue;
603        };
604        let Some((charset, collation)) = fetch_column_collation(tx, table, &raw_col).await? else {
605            continue;
606        };
607        if !boundaries_strictly_monotonic(tx, &splits.boundaries, &charset, &collation).await? {
608            return Err(TransientError::Generic(anyhow::anyhow!(
609                "collation of {table} changed during snapshot setup"
610            )));
611        }
612    }
613    Ok(())
614}
615
616/// A plain SQL identifier: non-empty, only ASCII alphanumerics and underscores. Used to
617/// gate charset/collation names before interpolating them (they can't be parameters).
618fn is_plain_ident(s: &str) -> bool {
619    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
620}
621
622fn is_decimal_literal(s: &str) -> bool {
623    let digits = s.strip_prefix('-').unwrap_or(s);
624    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
625}
626
627/// Returns the set of full tables/sections of tables to read.
628fn plan_worker_reads(
629    config: &RawSourceCreationConfig,
630    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
631    pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
632) -> BTreeMap<MySqlTableName, ReadPlan> {
633    tables
634        .keys()
635        .filter_map(|table| {
636            let plan = match pk_bounds.get(table) {
637                Some(Some(splits)) => worker_pk_range(
638                    splits,
639                    config.worker_id,
640                    config.responsible_worker(table),
641                    config.worker_count,
642                )
643                .map(ReadPlan::Range),
644                Some(None) => config
645                    .responsible_for(table)
646                    .then_some(ReadPlan::WholeTable),
647                None => panic!(
648                    "Programmer error: tables absent from pk_bounds failed schema \
649                     verification and are dropped before planning."
650                ),
651            };
652            plan.map(|plan| (table.clone(), plan))
653        })
654        .collect()
655}
656
657/// Renders the snapshot dataflow. See the module documentation for more information.
658pub(crate) fn render<'scope>(
659    scope: Scope<'scope, GtidPartition>,
660    config: RawSourceCreationConfig,
661    connection: MySqlSourceConnection,
662    source_outputs: Vec<SourceOutputInfo>,
663    metrics: MySqlSnapshotMetrics,
664) -> (
665    StackedCollection<'scope, GtidPartition, (usize, Result<SourceMessage, DataflowError>)>,
666    StreamVec<'scope, GtidPartition, RewindRequest>,
667    StreamVec<'scope, GtidPartition, ReplicationError>,
668    PressOnDropButton,
669) {
670    let mut builder =
671        AsyncOperatorBuilder::new(format!("MySqlSnapshotReader({})", config.id), scope.clone());
672
673    let (feedback_handle, feedback_data) = scope.feedback(Default::default());
674
675    let (raw_handle, raw_data) = builder.new_output::<FueledBuilder<_>>();
676    let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
677    // Captures DefiniteErrors that affect the entire source, including all outputs
678    let (definite_error_handle, definite_errors) =
679        builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
680    let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
681
682    // This operator needs to broadcast data to itself in order to synchronize the transaction
683    // snapshot. However, none of the feedback capabilities result in output messages and for the
684    // feedback edge specifically having a default connection would result in a loop.
685    let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
686
687    // The snapshot info must be sent to all workers, so we broadcast the feedback connection
688    snapshot.broadcast().connect_loop(feedback_handle);
689
690    let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
691
692    // A global view of all outputs that will be snapshot by all workers.
693    let mut all_outputs = vec![];
694    // The table infos to snapshot. Every worker holds all of them, since parallel
695    // PK-range reads split each table across workers.
696    let mut reader_snapshot_table_info = BTreeMap::new();
697    // Maps MySQL table name to export `SourceStatistics`. Same info exists in reader_snapshot_table_info,
698    // but this avoids having to iterate + map each time the statistics are needed.
699    let mut export_statistics = BTreeMap::new();
700    for output in source_outputs.into_iter() {
701        // Determine which outputs need to be snapshot and which already have been.
702        if *output.resume_upper != [GtidPartition::minimum()] {
703            // Already has been snapshotted.
704            continue;
705        }
706        all_outputs.push(output.output_index);
707        let export_stats = config
708            .statistics
709            .get(&output.export_id)
710            .expect("statistics have been intialized")
711            .clone();
712        export_statistics
713            .entry(output.table_name.clone())
714            .or_insert_with(Vec::new)
715            .push(export_stats);
716
717        reader_snapshot_table_info
718            .entry(output.table_name.clone())
719            .or_insert_with(Vec::new)
720            .push(output);
721    }
722
723    let (button, transient_errors): (_, StreamVec<'scope, GtidPartition, Rc<TransientError>>) =
724        builder.build_fallible(move |caps| {
725            let busy_signal = Arc::clone(&config.busy_signal);
726            Box::pin(SignaledFuture::new(busy_signal, async move {
727                let [
728                    data_cap_set,
729                    rewind_cap_set,
730                    definite_error_cap_set,
731                    snapshot_cap_set,
732                ]: &mut [_; 4] = caps.try_into().unwrap();
733
734                let id = config.id;
735                let worker_id = config.worker_id;
736
737                if !all_outputs.is_empty() {
738                    // A worker *must* emit a count even if not responsible for snapshotting a table
739                    // as statistic summarization will return null if any worker hasn't set a value.
740                    // This will also reset snapshot stats for any exports not snapshotting.
741                    for statistics in config.statistics.values() {
742                        statistics.set_snapshot_records_known(0);
743                        statistics.set_snapshot_records_staged(0);
744                    }
745                }
746
747                // If this worker has no tables to snapshot then there is nothing to do.
748                if reader_snapshot_table_info.is_empty() {
749                    trace!(%id, "timely-{worker_id} initializing table reader \
750                                 with no tables to snapshot, exiting");
751                    return Ok(());
752                } else {
753                    trace!(%id, "timely-{worker_id} initializing table reader \
754                                 with {} tables to snapshot",
755                           reader_snapshot_table_info.len());
756                }
757
758                let connection_config = connection
759                    .connection
760                    .config(
761                        &config.config.connection_context.secrets_reader,
762                        &config.config,
763                        InTask::Yes,
764                    )
765                    .await?;
766                let task_name = format!("timely-{worker_id} MySQL snapshotter");
767
768                // Per-table row counts, computed once during PK sampling and reused
769                // by the leader to publish the snapshot size gauge.
770                let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
771
772                let mut conn = connection_config
773                    .connect(
774                        &task_name,
775                        &config.config.connection_context.ssh_tunnel_manager,
776                    )
777                    .await?;
778
779                // Verify the MySQL system settings are correct for consistent row-based replication using GTIDs
780                match validate_mysql_repl_settings(&mut conn).await {
781                    Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
782                        return Ok(return_definite_error(
783                            DefiniteError::ServerConfigurationError(err.to_string()),
784                            &all_outputs,
785                            &raw_handle,
786                            data_cap_set,
787                            &definite_error_handle,
788                            definite_error_cap_set,
789                        )
790                        .await);
791                    }
792                    Err(err) => Err(err)?,
793                    Ok(()) => (),
794                };
795
796                if let Some(timeout) = config
797                    .config
798                    .parameters
799                    .mysql_source_timeouts
800                    .snapshot_wait_timeout
801                {
802                    set_wait_timeout(&mut *conn, timeout).await?;
803                }
804
805                let mut lock_conn = if is_snapshot_leader {
806                    match lock_and_prepare_snapshot(
807                        &config,
808                        &connection_config,
809                        &task_name,
810                        &reader_snapshot_table_info,
811                        &metrics,
812                    )
813                    .await
814                    {
815                        Ok((info, counts, conn)) => {
816                            snapshot_counts = counts;
817                            trace!(%id, "timely-{worker_id} broadcasting snapshot info: {info:?}");
818                            snapshot_handle.give(&snapshot_cap_set[0], Some(info));
819                            Some(conn)
820                        }
821                        Err(err) => {
822                            // Broadcast the failure sentinel so non-leaders exit cleanly instead
823                            // of deadlocking on the feedback loop.
824                            snapshot_handle.give(&snapshot_cap_set[0], None);
825                            return Err(err);
826                        }
827                    }
828                } else {
829                    None
830                };
831
832                // Receive the leader's broadcast: `Some` on success, `None` on leader failure.
833                let snapshot_info: Option<SnapshotInfo> = loop {
834                    match snapshot_input.next().await {
835                        Some(AsyncEvent::Data(_, mut data)) => {
836                            if let Some(msg) = data.pop() {
837                                break msg;
838                            }
839                        }
840                        Some(AsyncEvent::Progress(_)) => continue,
841                        // Feedback closed without data: the leader failed and already
842                        // propagated the error.
843                        None => break None,
844                    }
845                };
846                let snapshot_info = match snapshot_info {
847                    Some(info) => info,
848                    None => return Ok(()),
849                };
850
851                let errored: BTreeMap<usize, DefiniteError> =
852                    snapshot_info.errored_outputs.iter().cloned().collect();
853                let errored_outputs: Vec<_> = reader_snapshot_table_info
854                    .values()
855                    .flatten()
856                    .filter_map(|output| {
857                        errored.get(&output.output_index).map(|err| (output, err))
858                    })
859                    .collect();
860                let mut removed_outputs = BTreeSet::new();
861                for (output, err) in errored_outputs {
862                    removed_outputs.insert(output.output_index);
863                    // Only the responsible worker publishes any error,
864                    // so it lands once instead of once per worker reading the table.
865                    if !config.responsible_for(&output.table_name) {
866                        continue;
867                    }
868                    let update = (
869                        (output.output_index, Err(err.clone().into())),
870                        GtidPartition::minimum(),
871                        Diff::ONE,
872                    );
873                    let size = update.fuel_size();
874                    raw_handle.give_fueled(&data_cap_set[0], update, size).await;
875                    tracing::warn!(%id, "timely-{worker_id} stopping snapshot of output {output:?} \
876                                due to schema mismatch");
877                }
878                for (_, outputs) in reader_snapshot_table_info.iter_mut() {
879                    outputs.retain(|output| !removed_outputs.contains(&output.output_index));
880                }
881                reader_snapshot_table_info.retain(|_, outputs| !outputs.is_empty());
882
883                let snapshot_gtid_frontier = match gtid_set_frontier(&snapshot_info.gtid_set) {
884                    Ok(frontier) => frontier,
885                    Err(err) => {
886                        // If we received a GTID Set with non-consecutive intervals this breaks all
887                        // our assumptions, so there is nothing else we can do.
888                        return Ok(return_definite_error(
889                            DefiniteError::UnsupportedGtidState(err.to_string()),
890                            &all_outputs,
891                            &raw_handle,
892                            data_cap_set,
893                            &definite_error_handle,
894                            definite_error_cap_set,
895                        )
896                        .await);
897                    }
898                };
899
900                trace!(%id, "timely-{worker_id} received snapshot info at: {}",
901                       snapshot_gtid_frontier.pretty());
902
903                let table_ranges = plan_worker_reads(
904                    &config,
905                    &reader_snapshot_table_info,
906                    &snapshot_info.pk_bounds,
907                );
908                let has_work = !table_ranges.is_empty();
909
910                // Returning will release the snapshot capabilities unblocking the leader from dropping the lock connection.
911                if !has_work && !is_snapshot_leader {
912                    trace!(%id, "timely-{worker_id} has no tables to snapshot.");
913                    return Ok(());
914                }
915
916                trace!(%id, "timely-{worker_id} starting transaction with \
917                             consistent snapshot at: {}", snapshot_gtid_frontier.pretty());
918
919                // Start a transaction with REPEATABLE READ and 'CONSISTENT SNAPSHOT' semantics
920                // so we can read a consistent snapshot of the table at the specific GTID we read.
921                let mut tx_opts = TxOpts::default();
922                tx_opts
923                    .with_isolation_level(IsolationLevel::RepeatableRead)
924                    .with_consistent_snapshot(true)
925                    .with_readonly(true);
926                let mut tx = conn.start_transaction(tx_opts).await?;
927                // Set the session time zone to UTC so that we can read TIMESTAMP columns as UTC
928                // From https://dev.mysql.com/doc/refman/8.0/en/datetime.html: "MySQL converts TIMESTAMP values
929                // from the current time zone to UTC for storage, and back from UTC to the current time zone
930                // for retrieval. (This does not occur for other types such as DATETIME.)"
931                #[allow(clippy::disallowed_methods)] // static SQL string
932                tx.query_drop("set @@session.time_zone = '+00:00'").await?;
933
934                // Configure query execution time based on param. We want to be able to
935                // override the server value here in case it's set too low,
936                // respective to the size of the data we need to copy.
937                if let Some(timeout) = config
938                    .config
939                    .parameters
940                    .mysql_source_timeouts
941                    .snapshot_max_execution_time
942                {
943                    // Interpolating an integer millis value; not parameterizable in MySQL `SET`.
944                    #[allow(clippy::disallowed_methods)]
945                    tx.query_drop(format!(
946                        "SET @@session.max_execution_time = {}",
947                        timeout.as_millis()
948                    ))
949                    .await?;
950                }
951
952                // Signal readiness by dropping the snapshot capability, then the leader
953                // waits for every worker to signal before unlocking.
954                *snapshot_cap_set = CapabilitySet::new();
955                if is_snapshot_leader {
956                    while snapshot_input.next().await.is_some() {}
957                    if let Some(mut lc) = lock_conn.take() {
958                        #[allow(clippy::disallowed_methods)] // static SQL string
959                        lc.query_drop("UNLOCK TABLES").await?;
960                        lc.disconnect().await?;
961                    }
962                }
963                drop(lock_conn);
964
965                trace!(%id, "timely-{worker_id} started transaction (has_work={has_work}, is_snapshot_leader={is_snapshot_leader})");
966
967                // Verify the schemas of the tables we are snapshotting
968                let errored_outputs = verify_schemas(
969                    &mut tx,
970                    reader_snapshot_table_info
971                        .iter()
972                        .filter(|(t, _)| table_ranges.contains_key(t))
973                        .map(|(k, v)| (k, v.as_slice()))
974                        .collect(),
975                )
976                .await?;
977                if let Some((output, err)) = errored_outputs.into_iter().next() {
978                    return Err(TransientError::Generic(anyhow::anyhow!(
979                        "schema of {} changed during snapshot setup: {err}",
980                        output.table_name
981                    )));
982                }
983                verify_pk_bounds_monotonic(
984                    &mut tx,
985                    &reader_snapshot_table_info,
986                    &table_ranges,
987                    &snapshot_info.pk_bounds,
988                )
989                .await?;
990
991                // Only the leader publishes the full snapshot size, so the summed
992                // worker-local gauges reflect the upstream total without double-counting.
993                // The counts were computed once during PK sampling and are reused here.
994                if is_snapshot_leader {
995                    publish_snapshot_size(
996                        &snapshot_counts,
997                        &reader_snapshot_table_info,
998                        &export_statistics,
999                    );
1000                }
1001
1002                // This worker has nothing else to do
1003                if reader_snapshot_table_info.is_empty() {
1004                    return Ok(());
1005                }
1006
1007                // Read the snapshot data from the tables
1008                let mut final_row = Row::default();
1009
1010                let mut snapshot_staged_total = 0;
1011                for (table, outputs) in &reader_snapshot_table_info {
1012                    let pk_range = match table_ranges.get(table) {
1013                        Some(ReadPlan::Range(range)) => Some(range),
1014                        Some(ReadPlan::WholeTable) => None,
1015                        // This worker has no work for this table.
1016                        None => continue,
1017                    };
1018
1019                    let mut snapshot_staged = 0;
1020                    let query = build_snapshot_query(outputs, pk_range);
1021                    trace!(%id, "timely-{worker_id} reading snapshot query='{}'", query);
1022                    let mut results = tx.exec_stream(query, ()).await?;
1023                    while let Some(row) = results.try_next().await? {
1024                        let row: MySqlRow = row;
1025                        snapshot_staged += 1;
1026                        for (output, row_val) in outputs.iter().repeat_clone(row) {
1027                            // We don't need to verify if binlog_row_metadata matches the expected when snapshotting as
1028                            // the snapshot query always returns rows with full metadata. If the output is configured
1029                            // with binlog_full_metadata = false, then we will just ignore the metadata when decoding.
1030                            let event = match pack_mysql_row(
1031                                &mut final_row,
1032                                row_val,
1033                                &output.desc,
1034                                None,
1035                                output.binlog_full_metadata,
1036                            ) {
1037                                Ok(row) => Ok(SourceMessage {
1038                                    key: Row::default(),
1039                                    value: row,
1040                                    metadata: Row::default(),
1041                                }),
1042                                // Produce a DefiniteError in the stream for any rows that fail to decode
1043                                Err(err @ MySqlError::ValueDecodeError { .. }) => {
1044                                    Err(DataflowError::from(DefiniteError::ValueDecodeError(
1045                                        err.to_string(),
1046                                    )))
1047                                }
1048                                Err(err) => Err(err)?,
1049                            };
1050                            let update = (
1051                                (output.output_index, event),
1052                                GtidPartition::minimum(),
1053                                Diff::ONE,
1054                            );
1055                            let size = update.fuel_size();
1056                            raw_handle.give_fueled(&data_cap_set[0], update, size).await;
1057                        }
1058                        // This overcounting maintains existing behavior but will be removed once readers no longer rely on the value.
1059                        snapshot_staged_total += u64::cast_from(outputs.len());
1060                        if snapshot_staged_total % 1000 == 0 {
1061                            for statistics in export_statistics.get(table).unwrap() {
1062                                statistics.set_snapshot_records_staged(snapshot_staged);
1063                            }
1064                        }
1065                    }
1066                    for statistics in export_statistics.get(table).unwrap() {
1067                        statistics.set_snapshot_records_staged(snapshot_staged);
1068                    }
1069                    trace!(%id, "timely-{worker_id} snapshotted {} records from \
1070                                 table '{table}'", snapshot_staged * u64::cast_from(outputs.len()));
1071                }
1072
1073                // We are done with the snapshot so now we will emit rewind requests. It is
1074                // important that this happens after the snapshot has finished because this is what
1075                // unblocks the replication operator and we want this to happen serially. It might
1076                // seem like a good idea to read the replication stream concurrently with the
1077                // snapshot but it actually leads to a lot of data being staged for the future,
1078                // which needlesly consumed memory in the cluster.
1079                for (table, outputs) in &reader_snapshot_table_info {
1080                    if !config.responsible_for(table) {
1081                        continue;
1082                    }
1083                    for output in outputs {
1084                        trace!(%id, "timely-{worker_id} producing rewind request for {table}\
1085                                     output {}", output.output_index);
1086                        let req = RewindRequest {
1087                            output_index: output.output_index,
1088                            snapshot_upper: snapshot_gtid_frontier.clone(),
1089                        };
1090                        rewinds_handle.give(&rewind_cap_set[0], req);
1091                    }
1092                }
1093                *rewind_cap_set = CapabilitySet::new();
1094
1095                Ok(())
1096            }))
1097        });
1098
1099    // TODO: Split row decoding into a separate operator that can be distributed across all workers
1100
1101    let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
1102
1103    (
1104        raw_data.as_collection(),
1105        rewinds,
1106        errors,
1107        button.press_on_drop(),
1108    )
1109}
1110
1111/// Publish the snapshot size to each table's statistics gauges, using the counts
1112/// computed once during PK sampling. Called leader-only so the summed worker-local gauges
1113/// reflect the upstream total without double-counting.
1114fn publish_snapshot_size(
1115    counts: &BTreeMap<MySqlTableName, u64>,
1116    tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
1117    export_statistics: &BTreeMap<MySqlTableName, Vec<SourceStatistics>>,
1118) {
1119    for name in tables.keys() {
1120        let count = counts.get(name).copied().unwrap_or(0);
1121        let stats = export_statistics
1122            .get(name)
1123            .expect("statistics are initialized for each output");
1124        for export_stat in stats {
1125            export_stat.set_snapshot_records_known(count);
1126            export_stat.set_snapshot_records_staged(0);
1127        }
1128    }
1129}
1130
1131/// Sets the session wait_timeout so a lowered global value cannot reap the
1132/// connection while it sits idle during snapshot setup.
1133async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1134where
1135    Q: Queryable,
1136{
1137    // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1138    #[allow(clippy::disallowed_methods)]
1139    conn.query_drop(format!(
1140        "SET @@session.wait_timeout = {}",
1141        timeout.as_secs()
1142    ))
1143    .await
1144}
1145async fn lock_tables_and_read_gtid_set(
1146    lock_conn: &mut MySqlConn,
1147    lock_clauses: &str,
1148    lock_wait_timeout: Option<Duration>,
1149) -> Result<String, TransientError> {
1150    if let Some(timeout) = lock_wait_timeout {
1151        // Interpolating a `Duration` integer; not parameterizable in MySQL `SET`.
1152        #[allow(clippy::disallowed_methods)]
1153        lock_conn
1154            .query_drop(format!(
1155                "SET @@session.lock_wait_timeout = {}",
1156                timeout.as_secs()
1157            ))
1158            .await?;
1159    }
1160
1161    // `lock_clauses` is built from `MySqlTableName::Display`, which escapes both
1162    // schema and table via `quote_identifier`.
1163    if !lock_clauses.is_empty() {
1164        #[allow(clippy::disallowed_methods)]
1165        lock_conn
1166            .query_drop(format!("LOCK TABLES {lock_clauses}"))
1167            .await?;
1168    }
1169
1170    let snapshot_gtid_set = query_sys_var(lock_conn, "global.gtid_executed").await?;
1171    Ok(snapshot_gtid_set)
1172}
1173
1174/// Builds the SQL query to be used for creating the snapshot using the first entry in outputs.
1175///
1176/// Expect `outputs` to contain entries for a single table, and to have at least 1 entry.
1177/// Expect that each MySqlTableDesc entry contains all columns described in information_schema.columns.
1178///
1179/// When `pk_range` is provided, a WHERE clause is appended to filter rows by PK range.
1180#[must_use]
1181fn build_snapshot_query(outputs: &[SourceOutputInfo], pk_range: Option<&PkRange>) -> String {
1182    let info = outputs.first().expect("MySQL table info");
1183    for output in &outputs[1..] {
1184        // the columns may be decoded based on position, and different outputs may replicate
1185        // different columns, so we need to ensure that all columns are accounted for.
1186        assert!(
1187            info.desc.columns.len() == output.desc.columns.len(),
1188            "Mismatch in table descriptions for {}",
1189            info.table_name
1190        );
1191    }
1192    let columns = info
1193        .desc
1194        .columns
1195        .iter()
1196        .map(|col| quote_identifier(&col.name))
1197        .join(", ");
1198    let mut query = format!("SELECT {} FROM {}", columns, info.table_name);
1199    if let Some(range) = pk_range {
1200        // Half-open range on the PK column. The first/last partition omits its
1201        // open bound.
1202        let col = &range.pk_col;
1203        if let Some(lower) = &range.lower {
1204            query.push_str(&format!(" WHERE {col} >= {lower}"));
1205        }
1206        if let Some(upper) = &range.upper {
1207            let kw = if range.lower.is_some() {
1208                "AND"
1209            } else {
1210                "WHERE"
1211            };
1212            query.push_str(&format!(" {kw} {col} < {upper}"));
1213        }
1214    }
1215    query
1216}
1217
1218#[derive(Default)]
1219struct TableStatistics {
1220    count_latency: f64,
1221    count: u64,
1222}
1223
1224/// Row count for the snapshot size gauge. Tables whose optimizer row estimate exceeds
1225/// `exact_count_max_rows` report the estimate directly, everything else is counted
1226/// exactly with `COUNT(*)`. The gauge only drives progress reporting, and an estimate is
1227/// a fair trade for skipping an O(rows) index walk on a large table.
1228async fn collect_table_statistics<Q>(
1229    conn: &mut Q,
1230    table: &MySqlTableName,
1231    exact_count_max_rows: u64,
1232) -> Result<TableStatistics, TransientError>
1233where
1234    Q: Queryable,
1235{
1236    let mut stats = TableStatistics::default();
1237
1238    // The optimizer's row estimate for the table. InnoDB keeps it roughly current
1239    // (within the churn since the last stats recalculation), but it can be
1240    // stale-at-zero, in which case we fall through to the exact count. We don't expect
1241    // it to be null, but also fall back to count(*) in that case.
1242    let estimate: Option<Option<u64>> = conn
1243        .exec_first(
1244            "SELECT table_rows FROM information_schema.tables \
1245             WHERE table_schema = ? AND table_name = ?",
1246            (&table.0, &table.1),
1247        )
1248        .wall_time()
1249        .set_at(&mut stats.count_latency)
1250        .await?;
1251    match estimate.flatten() {
1252        Some(estimate) if estimate > exact_count_max_rows => {
1253            stats.count = estimate;
1254        }
1255        _ => {
1256            // `MySqlTableName::Display` escapes both identifier components via
1257            // `quote_identifier`, so this interpolation is safe; not parameterizable.
1258            #[allow(clippy::disallowed_methods)]
1259            let count_row: Option<u64> = conn
1260                .query_first(format!("SELECT COUNT(*) FROM {}", table))
1261                .wall_time()
1262                .set_at(&mut stats.count_latency)
1263                .await?;
1264            // `COUNT(*)` returns exactly one row, so `None` should be impossible.
1265            // Default to 0 defensively rather than failing the snapshot on a protocol
1266            // quirk.
1267            stats.count = count_row.unwrap_or(0);
1268        }
1269    }
1270
1271    Ok(stats)
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277    use mz_mysql_util::{MySqlColumnDesc, MySqlTableDesc};
1278    use timely::progress::Antichain;
1279
1280    #[mz_ore::test]
1281    fn snapshot_query_duplicate_table() {
1282        let schema_name = "myschema".to_string();
1283        let table_name = "mytable".to_string();
1284        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1285        let columns = ["c1", "c2", "c3"]
1286            .iter()
1287            .map(|col| MySqlColumnDesc {
1288                name: col.to_string(),
1289                column_type: None,
1290                meta: None,
1291            })
1292            .collect::<Vec<_>>();
1293        let desc = MySqlTableDesc {
1294            schema_name: schema_name.clone(),
1295            name: table_name.clone(),
1296            columns,
1297            keys: BTreeSet::default(),
1298        };
1299        let info = SourceOutputInfo {
1300            output_index: 1, // ignored
1301            table_name: table.clone(),
1302            desc,
1303            text_columns: vec![],
1304            exclude_columns: vec![],
1305            initial_gtid_set: Antichain::default(),
1306            resume_upper: Antichain::default(),
1307            export_id: mz_repr::GlobalId::User(1),
1308            binlog_full_metadata: false,
1309        };
1310        let query = build_snapshot_query(&[info.clone(), info], None);
1311        assert_eq!(
1312            format!(
1313                "SELECT `c1`, `c2`, `c3` FROM `{}`.`{}`",
1314                schema_name, table_name
1315            ),
1316            query
1317        );
1318    }
1319
1320    #[mz_ore::test]
1321    fn snapshot_query_with_pk_range() {
1322        let schema_name = "myschema".to_string();
1323        let table_name = "mytable".to_string();
1324        let table = MySqlTableName(schema_name.clone(), table_name.clone());
1325        let columns = ["id", "name"]
1326            .iter()
1327            .map(|col| MySqlColumnDesc {
1328                name: col.to_string(),
1329                column_type: None,
1330                meta: None,
1331            })
1332            .collect::<Vec<_>>();
1333        let desc = MySqlTableDesc {
1334            schema_name: schema_name.clone(),
1335            name: table_name.clone(),
1336            columns,
1337            keys: BTreeSet::default(),
1338        };
1339        let info = SourceOutputInfo {
1340            output_index: 1,
1341            table_name: table.clone(),
1342            desc,
1343            text_columns: vec![],
1344            exclude_columns: vec![],
1345            initial_gtid_set: Antichain::default(),
1346            resume_upper: Antichain::default(),
1347            export_id: mz_repr::GlobalId::User(1),
1348            binlog_full_metadata: false,
1349        };
1350
1351        // Middle worker: both bounds.
1352        let range = PkRange {
1353            pk_col: "`id`".to_string(),
1354            lower: Some("100".to_string()),
1355            upper: Some("200".to_string()),
1356        };
1357        let query = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1358        assert_eq!(
1359            format!(
1360                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= 100 AND `id` < 200",
1361                schema_name, table_name
1362            ),
1363            query
1364        );
1365
1366        // First worker: open start.
1367        let range = PkRange {
1368            pk_col: "`id`".to_string(),
1369            lower: None,
1370            upper: Some("200".to_string()),
1371        };
1372        let query = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1373        assert_eq!(
1374            format!(
1375                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` < 200",
1376                schema_name, table_name
1377            ),
1378            query
1379        );
1380
1381        // Last worker: open end.
1382        let range = PkRange {
1383            pk_col: "`id`".to_string(),
1384            lower: Some("200".to_string()),
1385            upper: None,
1386        };
1387        let query = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1388        assert_eq!(
1389            format!(
1390                "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= 200",
1391                schema_name, table_name
1392            ),
1393            query
1394        );
1395    }
1396
1397    #[mz_ore::test]
1398    fn test_worker_pk_range() {
1399        // Two partitions, boundary at 51. Owner 0 is the identity mapping
1400        // (partition == worker_id).
1401        let splits = PkBoundaries {
1402            pk_col: "`id`".to_string(),
1403            boundaries: vec!["51".to_string()],
1404        };
1405        let r0 = worker_pk_range(&splits, 0, 0, 4).expect("worker 0");
1406        assert_eq!(r0.pk_col, "`id`");
1407        assert_eq!(r0.lower, None); // open start
1408        assert_eq!(r0.upper.as_deref(), Some("51"));
1409        let r1 = worker_pk_range(&splits, 1, 0, 4).expect("worker 1");
1410        assert_eq!(r1.lower.as_deref(), Some("51"));
1411        assert_eq!(r1.upper, None); // open end
1412        // A surplus worker beyond the partition count has no work.
1413        assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1414
1415        // Three partitions: the middle worker has both bounds.
1416        let splits = PkBoundaries {
1417            pk_col: "`id`".to_string(),
1418            boundaries: vec!["34".to_string(), "67".to_string()],
1419        };
1420        let r1 = worker_pk_range(&splits, 1, 0, 3).expect("worker 1");
1421        assert_eq!(r1.lower.as_deref(), Some("34"));
1422        assert_eq!(r1.upper.as_deref(), Some("67"));
1423
1424        // Offsetting by the owner still assigns every worker a distinct partition,
1425        // and the owner reads the open-started first partition.
1426        let owner = 2;
1427        let owned = worker_pk_range(&splits, owner, owner, 3).expect("owner has work");
1428        assert_eq!(owned.lower, None);
1429        let mut ranges: Vec<_> = (0..3)
1430            .map(|w| {
1431                let r = worker_pk_range(&splits, w, owner, 3).expect("worker has work");
1432                (r.lower, r.upper)
1433            })
1434            .collect();
1435        ranges.sort();
1436        assert_eq!(
1437            ranges,
1438            vec![
1439                (None, Some("34".to_string())),
1440                (Some("34".to_string()), Some("67".to_string())),
1441                (Some("67".to_string()), None),
1442            ]
1443        );
1444    }
1445
1446    #[mz_ore::test]
1447    fn test_single_column_pk() {
1448        use mz_mysql_util::MySqlKeyDesc;
1449        use mz_repr::SqlColumnType;
1450
1451        let col = |name: &str, ty: SqlScalarType| MySqlColumnDesc {
1452            name: name.to_string(),
1453            column_type: Some(SqlColumnType {
1454                scalar_type: ty,
1455                nullable: false,
1456            }),
1457            meta: None,
1458        };
1459        let pk = |cols: &[&str]| {
1460            BTreeSet::from([MySqlKeyDesc {
1461                name: "PRIMARY".to_string(),
1462                is_primary: true,
1463                columns: cols.iter().map(|c| c.to_string()).collect(),
1464            }])
1465        };
1466        let desc = |columns, keys| MySqlTableDesc {
1467            schema_name: "s".to_string(),
1468            name: "t".to_string(),
1469            columns,
1470            keys,
1471        };
1472
1473        // Single-column PK: returns the raw column name and its type.
1474        let (name, ty) = try_extract_single_column_pk(&desc(
1475            vec![col("id", SqlScalarType::Char { length: None })],
1476            pk(&["id"]),
1477        ))
1478        .expect("single-column pk");
1479        assert_eq!(name, "id");
1480        assert!(matches!(ty, SqlScalarType::Char { .. }));
1481
1482        let (name, ty) =
1483            try_extract_single_column_pk(&desc(vec![col("id", SqlScalarType::Bytes)], pk(&["id"])))
1484                .expect("single-column pk");
1485        assert_eq!(name, "id");
1486        assert!(matches!(ty, SqlScalarType::Bytes));
1487
1488        // Composite PK → not a single column, fall back.
1489        assert!(
1490            try_extract_single_column_pk(&desc(
1491                vec![
1492                    col("a", SqlScalarType::Char { length: None }),
1493                    col("b", SqlScalarType::Int64),
1494                ],
1495                pk(&["a", "b"]),
1496            ))
1497            .is_none()
1498        );
1499
1500        // No primary key → fall back.
1501        assert!(
1502            try_extract_single_column_pk(&desc(
1503                vec![col("id", SqlScalarType::Int64)],
1504                BTreeSet::default()
1505            ))
1506            .is_none()
1507        );
1508    }
1509}