Skip to main content

mz_storage/source/
mysql.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//! Code to render the ingestion dataflow of a [`MySqlSourceConnection`].
11//!
12//! This dataflow is split into Snapshot and Replication operators.
13//!
14//! # Snapshot
15//!
16//! The snapshot operator is responsible for taking a consistent snapshot of the tables involved
17//! in the ingestion from the MySQL server. Each table that is being ingested is snapshot is
18//! assigned a specific worker, which performs a `SELECT * FROM table` and emits updates for all
19//! the rows in the given table.
20//!
21//! For all tables that are snapshotted the snapshot operator also emits a rewind request to
22//! the replication operator containing the GTID-set based frontier which will be used to
23//! ensure that the requested portion of the replication stream is subtracted from the snapshot.
24//!
25//! See the [snapshot] module for more information.
26//!
27//! # Replication
28//!
29//! The replication operator is responsible for ingesting the MySQL replication stream which must
30//! happen from a single worker.
31//!
32//! See the [replication] module for more information.
33//!
34//! # Error handling
35//!
36//! There are two kinds of errors that can happen during ingestion that are represented as two
37//! separate error types:
38//!
39//! [`DefiniteError`]s are errors that happen during processing of a specific collection record.
40//! These are the only errors that can ever end up in the error collection of a subsource.
41//!
42//! [`TransientError`]s are any errors that can happen for reasons that are unrelated to the data
43//! itself. This could be authentication failures, connection failures, etc. The only operators
44//! that can emit such errors are the `MySqlReplicationReader` and the `MySqlSnapshotReader`
45//! operators, which are the ones that talk to the external world. Both of these operators are
46//! built with the `AsyncOperatorBuilder::build_fallible` method which allows transient errors
47//! to be propagated upwards with the standard `?` operator without risking downgrading the
48//! capability and producing bogus frontiers.
49//!
50//! The error streams from both of those operators are published to the source status and also
51//! trigger a restart of the dataflow.
52
53use std::collections::BTreeMap;
54use std::fmt;
55use std::io;
56use std::rc::Rc;
57
58use itertools::Itertools;
59use mz_mysql_util::SchemaChangeError;
60use mz_mysql_util::quote_identifier;
61use mz_repr::Diff;
62use mz_repr::GlobalId;
63use mz_storage_types::errors::{DataflowError, SourceError};
64use mz_storage_types::sources::SourceExport;
65use serde::{Deserialize, Serialize};
66use timely::container::CapacityContainerBuilder;
67use timely::dataflow::operators::vec::{Map, ToStream};
68use timely::dataflow::operators::{CapabilitySet, Concat};
69use timely::dataflow::{Scope, StreamVec};
70use timely::progress::Antichain;
71use uuid::Uuid;
72
73use mz_mysql_util::{
74    MySqlError, MySqlTableDesc, ensure_full_row_binlog_format, ensure_gtid_consistency,
75    ensure_replication_commit_order,
76};
77use mz_ore::error::ErrorExt;
78use mz_storage_types::errors::SourceErrorDetails;
79use mz_storage_types::sources::mysql::{GtidPartition, GtidState, gtid_set_frontier};
80use mz_storage_types::sources::{MySqlSourceConnection, SourceExportDetails, SourceTimestamp};
81use mz_timely_util::builder_async::{AsyncOutputHandle, PressOnDropButton};
82use mz_timely_util::order::Extrema;
83
84use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
85use crate::source::types::Probe;
86use crate::source::types::{ResumeUppers, SharedFuel, SourceRender, StackedCollection};
87use crate::source::{RawSourceCreationConfig, SourceMessage};
88
89mod replication;
90mod schemas;
91mod snapshot;
92mod statistics;
93
94impl SourceRender for MySqlSourceConnection {
95    type Time = GtidPartition;
96
97    const STATUS_NAMESPACE: StatusNamespace = StatusNamespace::MySql;
98
99    /// Render the ingestion dataflow. This function only connects things together and contains no
100    /// actual processing logic.
101    fn render<'scope>(
102        self,
103        scope: Scope<'scope, GtidPartition>,
104        config: &RawSourceCreationConfig,
105        resume_uppers: impl futures::Stream<Item = ResumeUppers<GtidPartition>> + 'static,
106        _start_signal: impl std::future::Future<Output = ()> + 'static,
107    ) -> (
108        BTreeMap<
109            GlobalId,
110            StackedCollection<'scope, GtidPartition, Result<SourceMessage, DataflowError>>,
111        >,
112        StreamVec<'scope, GtidPartition, HealthStatusMessage>,
113        StreamVec<'scope, GtidPartition, Probe<GtidPartition>>,
114        Vec<PressOnDropButton>,
115    ) {
116        // Collect the source outputs that we will be exporting.
117        let mut source_outputs = Vec::new();
118        for (idx, (id, export)) in config.source_exports.iter().enumerate() {
119            let SourceExport {
120                details,
121                storage_metadata: _,
122                data_config: _,
123            } = export;
124            let details = match details {
125                SourceExportDetails::MySql(details) => details,
126                // This is an export that doesn't need any data output to it.
127                SourceExportDetails::None => continue,
128                _ => panic!("unexpected source export details: {:?}", details),
129            };
130
131            let desc = details.table.clone();
132            let initial_gtid_set = details.initial_gtid_set.to_string();
133            let resume_upper = Antichain::from_iter(
134                config
135                    .source_resume_uppers
136                    .get(id)
137                    .expect("missing resume upper")
138                    .iter()
139                    .map(GtidPartition::decode_row),
140            );
141            let name = MySqlTableName::new(&desc.schema_name, &desc.name);
142            source_outputs.push(SourceOutputInfo {
143                output_index: idx,
144                table_name: name.clone(),
145                desc,
146                text_columns: details.text_columns.clone(),
147                exclude_columns: details.exclude_columns.clone(),
148                initial_gtid_set: gtid_set_frontier(&initial_gtid_set).expect("invalid gtid set"),
149                resume_upper,
150                export_id: id.clone(),
151                binlog_full_metadata: details.binlog_full_metadata,
152            });
153        }
154
155        let metrics = config.metrics.get_mysql_source_metrics(config.id);
156
157        let (snapshot_updates, rewinds, snapshot_err, snapshot_token) = snapshot::render(
158            scope.clone(),
159            config.clone(),
160            self.clone(),
161            source_outputs.clone(),
162            metrics.snapshot_metrics.clone(),
163        );
164
165        let (repl_updates, repl_err, repl_token) = replication::render(
166            scope.clone(),
167            config.clone(),
168            self.clone(),
169            source_outputs,
170            rewinds,
171            metrics,
172        );
173
174        let (stats_err, probe_stream, stats_token) = statistics::render(
175            scope.clone(),
176            config.clone(),
177            self,
178            resume_uppers,
179            snapshot_err.clone().concat(repl_err.clone()),
180        );
181
182        // Both operators produce one stream per export in output index order. `source_exports`
183        // iterates in the same order.
184        let mut data_collections = BTreeMap::new();
185        for ((id, snapshot_updates), repl_updates) in config
186            .source_exports
187            .keys()
188            .zip_eq(snapshot_updates)
189            .zip_eq(repl_updates)
190        {
191            data_collections.insert(*id, snapshot_updates.concat(repl_updates));
192        }
193
194        let export_ids = config.source_exports.keys().copied();
195        let health_init = export_ids
196            .map(Some)
197            .chain(std::iter::once(None))
198            .map(|id| HealthStatusMessage {
199                id,
200                namespace: Self::STATUS_NAMESPACE,
201                update: HealthStatusUpdate::Running,
202            })
203            .collect::<Vec<_>>()
204            .to_stream(scope);
205
206        let health_errs = snapshot_err
207            .concat(repl_err)
208            .concat(stats_err)
209            .map(move |err| {
210                // This update will cause the dataflow to restart
211                let err_string = err.display_with_causes().to_string();
212                let hint = match &err {
213                    ReplicationError::Definite(err) => err.hint(),
214                    ReplicationError::Transient(_) => None,
215                };
216                let update = HealthStatusUpdate::halting(err_string.clone(), hint);
217
218                let namespace = match err {
219                    ReplicationError::Transient(err)
220                        if matches!(&*err, TransientError::MySqlError(MySqlError::Ssh(_))) =>
221                    {
222                        StatusNamespace::Ssh
223                    }
224                    _ => Self::STATUS_NAMESPACE,
225                };
226
227                HealthStatusMessage {
228                    id: None,
229                    namespace: namespace.clone(),
230                    update,
231                }
232            });
233        let health = health_init.concat(health_errs);
234
235        (
236            data_collections,
237            health,
238            probe_stream,
239            vec![snapshot_token, repl_token, stats_token],
240        )
241    }
242}
243
244#[derive(Clone, Debug)]
245struct SourceOutputInfo {
246    output_index: usize,
247    table_name: MySqlTableName,
248    desc: MySqlTableDesc,
249    text_columns: Vec<String>,
250    exclude_columns: Vec<String>,
251    initial_gtid_set: Antichain<GtidPartition>,
252    resume_upper: Antichain<GtidPartition>,
253    export_id: GlobalId,
254    binlog_full_metadata: bool,
255}
256
257#[derive(Clone, Debug, thiserror::Error)]
258pub enum ReplicationError {
259    #[error(transparent)]
260    Transient(#[from] Rc<TransientError>),
261    #[error(transparent)]
262    Definite(#[from] Rc<DefiniteError>),
263}
264
265/// A transient error that never ends up in the collection of a specific table.
266#[derive(Debug, thiserror::Error)]
267pub enum TransientError {
268    #[error("couldn't decode binlog row")]
269    BinlogRowDecodeError(#[from] mysql_async::binlog::row::BinlogRowToRowError),
270    #[error("stream ended prematurely")]
271    ReplicationEOF,
272    #[error(transparent)]
273    IoError(#[from] io::Error),
274    #[error("sql client error")]
275    SQLClient(#[from] mysql_async::Error),
276    #[error("ident decode error")]
277    IdentError(#[from] mz_sql_parser::ast::IdentError),
278    #[error(transparent)]
279    MySqlError(#[from] MySqlError),
280    #[error(transparent)]
281    Generic(#[from] anyhow::Error),
282}
283
284/// A definite error that always ends up in the collection of a specific table.
285#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
286pub enum DefiniteError {
287    #[error("unable to decode: {0}")]
288    ValueDecodeError(String),
289    #[error("table was truncated: {0}")]
290    TableTruncated(String),
291    #[error("table was dropped: {0}")]
292    TableDropped(String),
293    #[error(transparent)]
294    IncompatibleSchema(SchemaChangeError),
295    #[error("received a gtid set from the server that violates our requirements: {0}")]
296    UnsupportedGtidState(String),
297    #[error("received out of order gtids for source {0} at transaction-id {1}")]
298    BinlogGtidMonotonicityViolation(String, GtidState),
299    #[error("mysql server does not have the binlog available at the requested gtid set")]
300    BinlogNotAvailable,
301    #[error("mysql server binlog frontier at {0} is beyond required frontier {1}")]
302    BinlogMissingResumePoint(String, String),
303    #[error("mysql server configuration: {0}")]
304    ServerConfigurationError(String),
305}
306
307impl DefiniteError {
308    fn hint(&self) -> Option<String> {
309        match self {
310            DefiniteError::IncompatibleSchema(err) => err.hint(),
311            _ => None,
312        }
313    }
314}
315
316impl From<DefiniteError> for DataflowError {
317    fn from(err: DefiniteError) -> Self {
318        let m = err.to_string().into();
319        DataflowError::SourceError(Box::new(SourceError {
320            hint: err.hint().map(Into::into),
321            error: match &err {
322                DefiniteError::ValueDecodeError(_) => SourceErrorDetails::Other(m),
323                DefiniteError::TableTruncated(_) => SourceErrorDetails::Other(m),
324                DefiniteError::TableDropped(_) => SourceErrorDetails::Other(m),
325                DefiniteError::IncompatibleSchema(_) => SourceErrorDetails::Other(m),
326                DefiniteError::UnsupportedGtidState(_) => SourceErrorDetails::Other(m),
327                DefiniteError::BinlogGtidMonotonicityViolation(_, _) => {
328                    SourceErrorDetails::Other(m)
329                }
330                DefiniteError::BinlogNotAvailable => SourceErrorDetails::Initialization(m),
331                DefiniteError::BinlogMissingResumePoint(_, _) => {
332                    SourceErrorDetails::Initialization(m)
333                }
334                DefiniteError::ServerConfigurationError(_) => SourceErrorDetails::Initialization(m),
335            },
336        }))
337    }
338}
339
340/// A reference to a MySQL table. (schema_name, table_name)
341/// NOTE: We do not use `mz_sql_parser::ast:UnresolvedItemName` because the serialization
342/// behavior is not what we need for mysql.
343#[derive(
344    Debug,
345    Clone,
346    PartialEq,
347    Eq,
348    PartialOrd,
349    Ord,
350    Serialize,
351    Deserialize,
352    Hash
353)]
354pub(crate) struct MySqlTableName(pub(crate) String, pub(crate) String);
355
356impl MySqlTableName {
357    pub(crate) fn new(schema_name: &str, table_name: &str) -> Self {
358        Self(schema_name.to_string(), table_name.to_string())
359    }
360}
361
362impl fmt::Display for MySqlTableName {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        write!(
365            f,
366            "{}.{}",
367            quote_identifier(&self.0),
368            quote_identifier(&self.1)
369        )
370    }
371}
372
373impl From<&MySqlTableDesc> for MySqlTableName {
374    fn from(desc: &MySqlTableDesc) -> Self {
375        Self::new(&desc.schema_name, &desc.name)
376    }
377}
378
379#[derive(Debug, Clone, Deserialize, Serialize)]
380pub(crate) struct RewindRequest {
381    /// The output index that should be rewound.
382    pub(crate) output_index: usize,
383    /// The frontier of GTIDs that this snapshot represents; all GTIDs that are not beyond this
384    /// frontier have been committed by the snapshot operator at timestamp 0.
385    pub(crate) snapshot_upper: Antichain<GtidPartition>,
386}
387
388/// The data output ports of an operator, one per source export in output index order.
389type DataOutputs = SharedFuel<
390    GtidPartition,
391    CapacityContainerBuilder<Vec<(Result<SourceMessage, DataflowError>, GtidPartition, Diff)>>,
392>;
393
394async fn return_definite_error(
395    err: DefiniteError,
396    outputs: &[usize],
397    data_outputs: &mut DataOutputs,
398    data_cap_sets: &[CapabilitySet<GtidPartition>],
399    definite_error_handle: &AsyncOutputHandle<
400        GtidPartition,
401        CapacityContainerBuilder<Vec<ReplicationError>>,
402    >,
403    definite_error_cap_set: &CapabilitySet<GtidPartition>,
404) {
405    tracing::warn!("Returning definite error: {err}");
406    for &output_index in outputs {
407        let update = (
408            Err(err.clone().into()),
409            GtidPartition::new_range(Uuid::minimum(), Uuid::maximum(), GtidState::MAX),
410            Diff::ONE,
411        );
412        data_outputs
413            .give(output_index, &data_cap_sets[output_index][0], update)
414            .await;
415    }
416    definite_error_handle.give(
417        &definite_error_cap_set[0],
418        ReplicationError::Definite(Rc::new(err)),
419    );
420    ()
421}
422
423async fn validate_mysql_repl_settings(conn: &mut mysql_async::Conn) -> Result<(), MySqlError> {
424    ensure_gtid_consistency(conn).await?;
425    ensure_full_row_binlog_format(conn).await?;
426    ensure_replication_commit_order(conn).await?;
427
428    Ok(())
429}