1use 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 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 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 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 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 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#[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#[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#[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 pub(crate) output_index: usize,
383 pub(crate) snapshot_upper: Antichain<GtidPartition>,
386}
387
388type 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}