1use std::collections::BTreeMap;
54use std::fmt;
55use std::io;
56use std::rc::Rc;
57
58use differential_dataflow::AsCollection;
59use itertools::Itertools;
60use mz_mysql_util::SchemaChangeError;
61use mz_mysql_util::quote_identifier;
62use mz_ore::cast::CastFrom;
63use mz_repr::Diff;
64use mz_repr::GlobalId;
65use mz_storage_types::errors::{DataflowError, SourceError};
66use mz_storage_types::sources::SourceExport;
67use mz_timely_util::containers::stack::FueledBuilder;
68use serde::{Deserialize, Serialize};
69use timely::container::CapacityContainerBuilder;
70use timely::dataflow::operators::core::Partition;
71use timely::dataflow::operators::vec::{Map, ToStream};
72use timely::dataflow::operators::{CapabilitySet, Concat};
73use timely::dataflow::{Scope, StreamVec};
74use timely::progress::Antichain;
75use uuid::Uuid;
76
77use mz_mysql_util::{
78 MySqlError, MySqlTableDesc, ensure_full_row_binlog_format, ensure_gtid_consistency,
79 ensure_replication_commit_order,
80};
81use mz_ore::error::ErrorExt;
82use mz_storage_types::errors::SourceErrorDetails;
83use mz_storage_types::sources::mysql::{GtidPartition, GtidState, gtid_set_frontier};
84use mz_storage_types::sources::{MySqlSourceConnection, SourceExportDetails, SourceTimestamp};
85use mz_timely_util::builder_async::{AsyncOutputHandle, PressOnDropButton};
86use mz_timely_util::order::Extrema;
87
88use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
89use crate::source::types::Probe;
90use crate::source::types::{FuelSize, ResumeUppers, SourceRender, StackedCollection};
91use crate::source::{RawSourceCreationConfig, SourceMessage};
92
93mod replication;
94mod schemas;
95mod snapshot;
96mod statistics;
97
98impl SourceRender for MySqlSourceConnection {
99 type Time = GtidPartition;
100
101 const STATUS_NAMESPACE: StatusNamespace = StatusNamespace::MySql;
102
103 fn render<'scope>(
106 self,
107 scope: Scope<'scope, GtidPartition>,
108 config: &RawSourceCreationConfig,
109 resume_uppers: impl futures::Stream<Item = ResumeUppers<GtidPartition>> + 'static,
110 _start_signal: impl std::future::Future<Output = ()> + 'static,
111 ) -> (
112 BTreeMap<
113 GlobalId,
114 StackedCollection<'scope, GtidPartition, Result<SourceMessage, DataflowError>>,
115 >,
116 StreamVec<'scope, GtidPartition, HealthStatusMessage>,
117 StreamVec<'scope, GtidPartition, Probe<GtidPartition>>,
118 Vec<PressOnDropButton>,
119 ) {
120 let mut source_outputs = Vec::new();
122 for (idx, (id, export)) in config.source_exports.iter().enumerate() {
123 let SourceExport {
124 details,
125 storage_metadata: _,
126 data_config: _,
127 } = export;
128 let details = match details {
129 SourceExportDetails::MySql(details) => details,
130 SourceExportDetails::None => continue,
132 _ => panic!("unexpected source export details: {:?}", details),
133 };
134
135 let desc = details.table.clone();
136 let initial_gtid_set = details.initial_gtid_set.to_string();
137 let resume_upper = Antichain::from_iter(
138 config
139 .source_resume_uppers
140 .get(id)
141 .expect("missing resume upper")
142 .iter()
143 .map(GtidPartition::decode_row),
144 );
145 let name = MySqlTableName::new(&desc.schema_name, &desc.name);
146 source_outputs.push(SourceOutputInfo {
147 output_index: idx,
148 table_name: name.clone(),
149 desc,
150 text_columns: details.text_columns.clone(),
151 exclude_columns: details.exclude_columns.clone(),
152 initial_gtid_set: gtid_set_frontier(&initial_gtid_set).expect("invalid gtid set"),
153 resume_upper,
154 export_id: id.clone(),
155 binlog_full_metadata: details.binlog_full_metadata,
156 });
157 }
158
159 let metrics = config.metrics.get_mysql_source_metrics(config.id);
160
161 let (snapshot_updates, rewinds, snapshot_err, snapshot_token) = snapshot::render(
162 scope.clone(),
163 config.clone(),
164 self.clone(),
165 source_outputs.clone(),
166 metrics.snapshot_metrics.clone(),
167 );
168
169 let (repl_updates, repl_err, repl_token) = replication::render(
170 scope.clone(),
171 config.clone(),
172 self.clone(),
173 source_outputs,
174 rewinds,
175 metrics,
176 );
177
178 let (stats_err, probe_stream, stats_token) = statistics::render(
179 scope.clone(),
180 config.clone(),
181 self,
182 resume_uppers,
183 snapshot_err.clone().concat(repl_err.clone()),
184 );
185
186 let updates = snapshot_updates.concat(repl_updates);
187 let partition_count = u64::cast_from(config.source_exports.len());
188 let data_streams: Vec<_> = updates
189 .inner
190 .partition::<CapacityContainerBuilder<_>, _, _>(
191 partition_count,
192 |((output, data), time, diff): (
193 (usize, Result<SourceMessage, DataflowError>),
194 _,
195 Diff,
196 )| {
197 let output = u64::cast_from(output);
198 (output, (data, time, diff))
199 },
200 );
201 let mut data_collections = BTreeMap::new();
202 for (id, data_stream) in config.source_exports.keys().zip_eq(data_streams) {
203 data_collections.insert(*id, data_stream.as_collection());
204 }
205
206 let export_ids = config.source_exports.keys().copied();
207 let health_init = export_ids
208 .map(Some)
209 .chain(std::iter::once(None))
210 .map(|id| HealthStatusMessage {
211 id,
212 namespace: Self::STATUS_NAMESPACE,
213 update: HealthStatusUpdate::Running,
214 })
215 .collect::<Vec<_>>()
216 .to_stream(scope);
217
218 let health_errs = snapshot_err
219 .concat(repl_err)
220 .concat(stats_err)
221 .map(move |err| {
222 let err_string = err.display_with_causes().to_string();
224 let hint = match &err {
225 ReplicationError::Definite(err) => err.hint(),
226 ReplicationError::Transient(_) => None,
227 };
228 let update = HealthStatusUpdate::halting(err_string.clone(), hint);
229
230 let namespace = match err {
231 ReplicationError::Transient(err)
232 if matches!(&*err, TransientError::MySqlError(MySqlError::Ssh(_))) =>
233 {
234 StatusNamespace::Ssh
235 }
236 _ => Self::STATUS_NAMESPACE,
237 };
238
239 HealthStatusMessage {
240 id: None,
241 namespace: namespace.clone(),
242 update,
243 }
244 });
245 let health = health_init.concat(health_errs);
246
247 (
248 data_collections,
249 health,
250 probe_stream,
251 vec![snapshot_token, repl_token, stats_token],
252 )
253 }
254}
255
256#[derive(Clone, Debug)]
257struct SourceOutputInfo {
258 output_index: usize,
259 table_name: MySqlTableName,
260 desc: MySqlTableDesc,
261 text_columns: Vec<String>,
262 exclude_columns: Vec<String>,
263 initial_gtid_set: Antichain<GtidPartition>,
264 resume_upper: Antichain<GtidPartition>,
265 export_id: GlobalId,
266 binlog_full_metadata: bool,
267}
268
269#[derive(Clone, Debug, thiserror::Error)]
270pub enum ReplicationError {
271 #[error(transparent)]
272 Transient(#[from] Rc<TransientError>),
273 #[error(transparent)]
274 Definite(#[from] Rc<DefiniteError>),
275}
276
277#[derive(Debug, thiserror::Error)]
279pub enum TransientError {
280 #[error("couldn't decode binlog row")]
281 BinlogRowDecodeError(#[from] mysql_async::binlog::row::BinlogRowToRowError),
282 #[error("stream ended prematurely")]
283 ReplicationEOF,
284 #[error(transparent)]
285 IoError(#[from] io::Error),
286 #[error("sql client error")]
287 SQLClient(#[from] mysql_async::Error),
288 #[error("ident decode error")]
289 IdentError(#[from] mz_sql_parser::ast::IdentError),
290 #[error(transparent)]
291 MySqlError(#[from] MySqlError),
292 #[error(transparent)]
293 Generic(#[from] anyhow::Error),
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
298pub enum DefiniteError {
299 #[error("unable to decode: {0}")]
300 ValueDecodeError(String),
301 #[error("table was truncated: {0}")]
302 TableTruncated(String),
303 #[error("table was dropped: {0}")]
304 TableDropped(String),
305 #[error(transparent)]
306 IncompatibleSchema(SchemaChangeError),
307 #[error("received a gtid set from the server that violates our requirements: {0}")]
308 UnsupportedGtidState(String),
309 #[error("received out of order gtids for source {0} at transaction-id {1}")]
310 BinlogGtidMonotonicityViolation(String, GtidState),
311 #[error("mysql server does not have the binlog available at the requested gtid set")]
312 BinlogNotAvailable,
313 #[error("mysql server binlog frontier at {0} is beyond required frontier {1}")]
314 BinlogMissingResumePoint(String, String),
315 #[error("mysql server configuration: {0}")]
316 ServerConfigurationError(String),
317}
318
319impl DefiniteError {
320 fn hint(&self) -> Option<String> {
321 match self {
322 DefiniteError::IncompatibleSchema(err) => err.hint(),
323 _ => None,
324 }
325 }
326}
327
328impl From<DefiniteError> for DataflowError {
329 fn from(err: DefiniteError) -> Self {
330 let m = err.to_string().into();
331 DataflowError::SourceError(Box::new(SourceError {
332 hint: err.hint().map(Into::into),
333 error: match &err {
334 DefiniteError::ValueDecodeError(_) => SourceErrorDetails::Other(m),
335 DefiniteError::TableTruncated(_) => SourceErrorDetails::Other(m),
336 DefiniteError::TableDropped(_) => SourceErrorDetails::Other(m),
337 DefiniteError::IncompatibleSchema(_) => SourceErrorDetails::Other(m),
338 DefiniteError::UnsupportedGtidState(_) => SourceErrorDetails::Other(m),
339 DefiniteError::BinlogGtidMonotonicityViolation(_, _) => {
340 SourceErrorDetails::Other(m)
341 }
342 DefiniteError::BinlogNotAvailable => SourceErrorDetails::Initialization(m),
343 DefiniteError::BinlogMissingResumePoint(_, _) => {
344 SourceErrorDetails::Initialization(m)
345 }
346 DefiniteError::ServerConfigurationError(_) => SourceErrorDetails::Initialization(m),
347 },
348 }))
349 }
350}
351
352#[derive(
356 Debug,
357 Clone,
358 PartialEq,
359 Eq,
360 PartialOrd,
361 Ord,
362 Serialize,
363 Deserialize,
364 Hash
365)]
366pub(crate) struct MySqlTableName(pub(crate) String, pub(crate) String);
367
368impl MySqlTableName {
369 pub(crate) fn new(schema_name: &str, table_name: &str) -> Self {
370 Self(schema_name.to_string(), table_name.to_string())
371 }
372}
373
374impl fmt::Display for MySqlTableName {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 write!(
377 f,
378 "{}.{}",
379 quote_identifier(&self.0),
380 quote_identifier(&self.1)
381 )
382 }
383}
384
385impl From<&MySqlTableDesc> for MySqlTableName {
386 fn from(desc: &MySqlTableDesc) -> Self {
387 Self::new(&desc.schema_name, &desc.name)
388 }
389}
390
391#[derive(Debug, Clone, Deserialize, Serialize)]
392pub(crate) struct RewindRequest {
393 pub(crate) output_index: usize,
395 pub(crate) snapshot_upper: Antichain<GtidPartition>,
398}
399
400type StackedAsyncOutputHandle<T, D> =
401 AsyncOutputHandle<T, FueledBuilder<CapacityContainerBuilder<Vec<(D, T, Diff)>>>>;
402
403async fn return_definite_error(
404 err: DefiniteError,
405 outputs: &[usize],
406 data_handle: &StackedAsyncOutputHandle<
407 GtidPartition,
408 (usize, Result<SourceMessage, DataflowError>),
409 >,
410 data_cap_set: &CapabilitySet<GtidPartition>,
411 definite_error_handle: &AsyncOutputHandle<
412 GtidPartition,
413 CapacityContainerBuilder<Vec<ReplicationError>>,
414 >,
415 definite_error_cap_set: &CapabilitySet<GtidPartition>,
416) {
417 tracing::warn!("Returning definite error: {err}");
418 for output_index in outputs {
419 let update = (
420 (*output_index, Err(err.clone().into())),
421 GtidPartition::new_range(Uuid::minimum(), Uuid::maximum(), GtidState::MAX),
422 Diff::ONE,
423 );
424 let size = update.fuel_size();
425 data_handle
426 .give_fueled(&data_cap_set[0], update, size)
427 .await;
428 }
429 definite_error_handle.give(
430 &definite_error_cap_set[0],
431 ReplicationError::Definite(Rc::new(err)),
432 );
433 ()
434}
435
436async fn validate_mysql_repl_settings(conn: &mut mysql_async::Conn) -> Result<(), MySqlError> {
437 ensure_gtid_consistency(conn).await?;
438 ensure_full_row_binlog_format(conn).await?;
439 ensure_replication_commit_order(conn).await?;
440
441 Ok(())
442}