1use std::cell::RefCell;
109use std::collections::{BTreeMap, BTreeSet};
110use std::rc::Rc;
111use std::sync::Arc;
112use std::time::Duration;
113
114use differential_dataflow::AsCollection;
115use futures::{StreamExt as _, TryStreamExt};
116use itertools::Itertools;
117use mysql_async::prelude::Queryable;
118use mysql_async::{IsolationLevel, Row as MySqlRow, Transaction, TxOpts, Value};
119use mz_mysql_util::{
120 MySqlConn, MySqlError, QualifiedTableRef, pack_mysql_row, query_sys_var, quote_identifier,
121};
122use mz_ore::cast::CastFrom;
123use mz_ore::future::InTask;
124use mz_ore::iter::IteratorExt;
125use mz_ore::metrics::MetricsFutureExt;
126use mz_repr::{Diff, Row, SqlScalarType};
127use mz_storage_types::dyncfgs::STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD;
128use mz_storage_types::errors::DataflowError;
129use mz_storage_types::sources::MySqlSourceConnection;
130use mz_storage_types::sources::mysql::{GtidPartition, gtid_set_frontier};
131use mz_timely_util::antichain::AntichainExt;
132use mz_timely_util::builder_async::{
133 Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
134};
135use timely::container::CapacityContainerBuilder;
136use timely::dataflow::channels::pact::Pipeline;
137use timely::dataflow::operators::core::Map;
138use timely::dataflow::operators::vec::Broadcast;
139use timely::dataflow::operators::{CapabilitySet, Concat, ConnectLoop, Feedback};
140use timely::dataflow::{Scope, StreamVec};
141use timely::progress::Timestamp;
142use tracing::trace;
143
144use crate::metrics::source::mysql::MySqlSnapshotMetrics;
145use crate::source::RawSourceCreationConfig;
146use crate::source::types::{SharedFuel, SignaledFuture, SourceMessage, StackedCollection};
147use crate::statistics::SourceStatistics;
148
149use super::schemas::verify_schemas;
150use super::{
151 DefiniteError, MySqlTableName, ReplicationError, RewindRequest, SourceOutputInfo,
152 TransientError, return_definite_error, validate_mysql_repl_settings,
153};
154
155fn try_extract_single_column_pk(
159 desc: &mz_mysql_util::MySqlTableDesc,
160) -> Option<(String, SqlScalarType)> {
161 let pk = desc.keys.iter().find(|k| k.is_primary)?;
162 let [name] = &pk.columns[..] else {
163 return None;
164 };
165 let col = desc.columns.iter().find(|c| &c.name == name)?;
166 if col.meta.is_some() {
167 return None;
168 }
169 let scalar_type = col.column_type.as_ref()?.scalar_type.clone();
170 Some((name.clone(), scalar_type))
171}
172
173#[derive(Clone, serde::Serialize, serde::Deserialize)]
174struct PkBoundaries {
175 pk_col: String,
176 boundaries: Vec<String>,
178}
179
180impl std::fmt::Debug for PkBoundaries {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.debug_struct("PkBoundaries")
185 .field("pk_col", &self.pk_col)
186 .field("boundaries", &mz_ore::str::redact(&self.boundaries))
187 .finish()
188 }
189}
190
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192struct SnapshotInfo {
193 gtid_set: String,
194 pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>>,
196 errored_outputs: Vec<(usize, DefiniteError)>,
197}
198
199struct PkRange {
200 pk_col: String,
202 lower: Option<String>,
204 upper: Option<String>,
206}
207
208enum ReadPlan {
210 Range(PkRange),
212 WholeTable,
214}
215
216fn worker_pk_range(
219 splits: &PkBoundaries,
220 worker_id: usize,
221 owner_worker_id: usize,
222 worker_count: usize,
223) -> Option<PkRange> {
224 let partition = (worker_id + worker_count - owner_worker_id) % worker_count;
225 let partitions = splits.boundaries.len() + 1;
226 if partition >= partitions {
227 return None;
228 }
229 Some(PkRange {
230 pk_col: splits.pk_col.clone(),
231 lower: (partition > 0).then(|| splits.boundaries[partition - 1].clone()),
232 upper: (partition < partitions - 1).then(|| splits.boundaries[partition].clone()),
233 })
234}
235
236const SUPPORTED_PK_COLLATION: &str = "utf8mb4_bin";
237const SUPPORTED_PK_CHARSET: &str = "utf8mb4";
238const MIN_PROBED_PREFIXES: u64 = 64;
239const MAX_PROBED_PREFIXES: u64 = 5_000;
240
241struct PartitionSettings {
243 min_rows: u64,
244 probed_prefixes_per_billion_rows: u64,
245}
246
247async fn compute_sampled_splits(
251 tx: &mut Transaction<'_>,
252 table: &MySqlTableName,
253 raw_col: &str,
254 scalar_type: &SqlScalarType,
255 worker_count: usize,
256 row_count: u64,
257 settings: &PartitionSettings,
258) -> Result<Option<PkBoundaries>, TransientError> {
259 match scalar_type {
260 SqlScalarType::Char { length }
261 if length.is_some_and(|l| l.into_u32() <= mz_mysql_util::MAX_KEY_LENGTH) => {}
262 SqlScalarType::VarChar { max_length }
263 if max_length.is_some_and(|l| l.into_u32() <= mz_mysql_util::MAX_KEY_LENGTH) => {}
264 _ => return Ok(None),
265 }
266 let collation = fetch_column_collation(&mut *tx, table, raw_col).await?;
267 let supported = matches!(&collation, Some(c) if c.1 == SUPPORTED_PK_COLLATION);
268 if !supported {
269 tracing::debug!(?collation, "PK splitting skipped: unsupported collation");
270 return Ok(None);
271 }
272 let table_ref = QualifiedTableRef {
273 schema_name: &table.0,
274 table_name: &table.1,
275 };
276 let max_probed_prefixes = (row_count.saturating_mul(settings.probed_prefixes_per_billion_rows)
280 / 1_000_000_000)
281 .clamp(MIN_PROBED_PREFIXES, MAX_PROBED_PREFIXES);
282 let params = mz_mysql_util::PartitionParams {
283 num_workers: worker_count,
284 estimated_row_count: row_count,
285 min_split_threshold: settings.min_rows,
286 max_probed_prefixes,
287 };
288 let prefixes = match mz_mysql_util::partition_table(tx, table_ref, raw_col, params).await {
289 Ok(prefixes) => prefixes,
290 Err(err @ (MySqlError::NonUtf8KeyValue { .. } | MySqlError::MissingRowEstimate { .. })) => {
291 tracing::warn!(%err, "partitioning failed, falling back to a single partition");
292 return Ok(None);
293 }
294 Err(err) => return Err(err.into()),
295 };
296 if prefixes.is_empty() {
297 return Ok(None);
298 }
299 Ok(Some(PkBoundaries {
300 pk_col: quote_identifier(raw_col),
301 boundaries: prefixes,
302 }))
303}
304
305async fn sample_pk_bounds(
312 config: &RawSourceCreationConfig,
313 connection_config: &mz_mysql_util::Config,
314 task_name: &str,
315 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
316 metrics: &MySqlSnapshotMetrics,
317) -> Result<
318 (
319 BTreeMap<MySqlTableName, Option<PkBoundaries>>,
320 BTreeMap<MySqlTableName, u64>,
321 ),
322 TransientError,
323> {
324 let ssh_tunnel_manager = &config.config.connection_context.ssh_tunnel_manager;
325 let worker_count = config.worker_count;
326 let max_execution_time = config
327 .config
328 .parameters
329 .mysql_source_timeouts
330 .snapshot_max_execution_time;
331 let parallelism_enabled = mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARALLELISM
335 .get(config.config.config_set());
336 let exact_count_max_rows = u64::cast_from(
337 mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS
338 .get(config.config.config_set()),
339 );
340 let min_rows = u64::cast_from(
341 mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS
342 .get(config.config.config_set()),
343 );
344 let probed_prefixes_per_billion_rows = u64::cast_from(
345 mz_storage_types::dyncfgs::MYSQL_SOURCE_SNAPSHOT_PARTITION_PROBED_PREFIXES_PER_BILLION_ROWS
346 .get(config.config.config_set()),
347 );
348 let partition_settings = &PartitionSettings {
349 min_rows,
350 probed_prefixes_per_billion_rows,
351 };
352
353 let pooled_conns: Rc<RefCell<Vec<MySqlConn>>> = Rc::new(RefCell::new(Vec::new()));
354 let per_table: Vec<(MySqlTableName, u64, Option<PkBoundaries>)> = futures::stream::iter(tables)
356 .map(|(table, outputs)| {
357 let pool = Rc::clone(&pooled_conns);
358 async move {
359 let pooled = pool.borrow_mut().pop();
362 let mut conn = match pooled {
363 Some(conn) => conn,
364 None => {
365 let mut conn = connection_config
366 .connect(task_name, ssh_tunnel_manager)
367 .await?;
368 if let Some(timeout) = max_execution_time {
369 #[allow(clippy::disallowed_methods)]
370 conn.query_drop(format!(
371 "SET @@session.max_execution_time = {}",
372 timeout.as_millis()
373 ))
374 .await?;
375 }
376 conn
377 }
378 };
379 let mut tx_opts = TxOpts::default();
381 tx_opts
382 .with_isolation_level(IsolationLevel::RepeatableRead)
383 .with_readonly(true);
384 let mut tx = conn.start_transaction(tx_opts).await?;
385 let stats = collect_table_statistics(&mut tx, table, exact_count_max_rows).await?;
391 metrics.record_table_count_latency(
392 table.1.clone(),
393 table.0.clone(),
394 stats.count_latency,
395 );
396 let count = stats.count;
397 let splits = match parallelism_enabled
399 .then(|| try_extract_single_column_pk(&outputs[0].desc))
400 .flatten()
401 {
402 Some((raw_col, scalar_type)) => {
403 compute_sampled_splits(
404 &mut tx,
405 table,
406 &raw_col,
407 &scalar_type,
408 worker_count,
409 count,
410 partition_settings,
411 )
412 .await?
413 }
414 None => None,
415 };
416 tx.rollback().await?;
418 pool.borrow_mut().push(conn);
419 Ok::<_, TransientError>((table.clone(), count, splits))
420 }
421 })
422 .buffer_unordered(worker_count)
425 .try_collect()
426 .await?;
427
428 let mut pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>> = BTreeMap::new();
429 let mut counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
430 for (table, count, splits) in per_table {
431 pk_bounds.insert(table.clone(), splits);
432 counts.insert(table, count);
433 }
434
435 let probe_conns = Rc::into_inner(pooled_conns)
439 .expect("all sampling futures completed, so no Rc clones remain")
440 .into_inner();
441 for conn in probe_conns {
442 conn.disconnect().await?;
443 }
444 Ok((pk_bounds, counts))
445}
446
447async fn lock_and_prepare_snapshot(
452 config: &RawSourceCreationConfig,
453 connection_config: &mz_mysql_util::Config,
454 task_name: &str,
455 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
456 metrics: &MySqlSnapshotMetrics,
457) -> Result<(SnapshotInfo, BTreeMap<MySqlTableName, u64>, MySqlConn), TransientError> {
458 let mut lock_conn = connection_config
459 .connect(
460 task_name,
461 &config.config.connection_context.ssh_tunnel_manager,
462 )
463 .await?;
464
465 if let Some(timeout) = config
466 .config
467 .parameters
468 .mysql_source_timeouts
469 .snapshot_wait_timeout
470 {
471 set_wait_timeout(&mut *lock_conn, timeout).await?;
472 }
473
474 let errored_outputs = verify_output_schemas(&mut *lock_conn, tables).await?;
475 let errored: BTreeSet<usize> = errored_outputs.iter().map(|(idx, _)| *idx).collect();
476 let sample_tables: BTreeMap<MySqlTableName, Vec<SourceOutputInfo>> = tables
477 .iter()
478 .map(|(table, outputs)| {
479 let outputs = outputs
480 .iter()
481 .filter(|o| !errored.contains(&o.output_index))
482 .cloned()
483 .collect::<Vec<_>>();
484 (table.clone(), outputs)
485 })
486 .filter(|(_, outputs)| !outputs.is_empty())
487 .collect();
488
489 let (pk_bounds, counts) = sample_pk_bounds(
491 config,
492 connection_config,
493 task_name,
494 &sample_tables,
495 metrics,
496 )
497 .await?;
498
499 let lock_clauses = sample_tables
500 .keys()
501 .map(|t| format!("{} READ", t))
502 .collect::<Vec<String>>()
503 .join(", ");
504
505 let snapshot_gtid_set = lock_tables_and_read_gtid_set(
507 &mut lock_conn,
508 &lock_clauses,
509 config
510 .config
511 .parameters
512 .mysql_source_timeouts
513 .snapshot_lock_wait_timeout,
514 )
515 .await?;
516
517 Ok((
518 SnapshotInfo {
519 gtid_set: snapshot_gtid_set,
520 pk_bounds,
521 errored_outputs,
522 },
523 counts,
524 lock_conn,
525 ))
526}
527
528async fn verify_output_schemas<Q>(
529 conn: &mut Q,
530 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
531) -> Result<Vec<(usize, DefiniteError)>, TransientError>
532where
533 Q: Queryable,
534{
535 let errored = verify_schemas(
536 conn,
537 tables.iter().map(|(k, v)| (k, v.as_slice())).collect(),
538 )
539 .await?;
540 Ok(errored
541 .into_iter()
542 .map(|(output, err)| (output.output_index, err))
543 .collect())
544}
545
546async fn fetch_column_collation<Q>(
549 conn: &mut Q,
550 table: &MySqlTableName,
551 column: &str,
552) -> Result<Option<(String, String)>, TransientError>
553where
554 Q: Queryable,
555{
556 let row: Option<(Option<String>, Option<String>)> = conn
557 .exec_first(
558 "SELECT character_set_name, collation_name \
559 FROM information_schema.columns \
560 WHERE table_schema = ? AND table_name = ? AND column_name = ?",
561 (&table.0, &table.1, column),
562 )
563 .await?;
564 Ok(row.and_then(|(charset, collation)| Some((charset?, collation?))))
566}
567
568async fn boundaries_strictly_monotonic<Q>(
574 conn: &mut Q,
575 boundaries: &[String],
576 charset: &str,
577 collation: &str,
578) -> Result<bool, TransientError>
579where
580 Q: Queryable,
581{
582 if boundaries.len() < 2 {
583 return Ok(true);
584 }
585 if !is_plain_ident(charset) || !is_plain_ident(collation) {
588 return Ok(false);
589 }
590 let term = format!("CONVERT(? USING {charset}) COLLATE {collation}");
591 let predicate = vec![format!("{term} < {term}"); boundaries.len() - 1].join(" AND ");
592 let params: Vec<Value> = boundaries
593 .windows(2)
594 .flat_map(|w| [w[0].as_str().into(), w[1].as_str().into()])
595 .collect();
596 let ok: Option<i64> = conn
597 .exec_first(format!("SELECT {predicate}"), params)
598 .await?;
599 Ok(ok == Some(1))
600}
601
602async fn verify_pk_bounds_monotonic<Q>(
603 tx: &mut Q,
604 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
605 table_ranges: &BTreeMap<MySqlTableName, ReadPlan>,
606 pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
607) -> Result<(), TransientError>
608where
609 Q: Queryable,
610{
611 for (table, plan) in table_ranges {
612 if !matches!(plan, ReadPlan::Range(_)) {
613 continue;
614 }
615 let Some(Some(splits)) = pk_bounds.get(table) else {
619 return Err(TransientError::Generic(anyhow::anyhow!(
620 "PK range planned for {table} without any PK bounds, which is unexpected"
621 )));
622 };
623 let Some((raw_col, _)) = tables
624 .get(table)
625 .and_then(|outputs| try_extract_single_column_pk(&outputs[0].desc))
626 else {
627 return Err(TransientError::Generic(anyhow::anyhow!(
628 "PK range planned for {table} without a single-column PK, which is unexpected"
629 )));
630 };
631 let ok = match fetch_column_collation(tx, table, &raw_col).await? {
632 Some((charset, collation))
635 if collation == SUPPORTED_PK_COLLATION && charset == SUPPORTED_PK_CHARSET =>
636 {
637 boundaries_strictly_monotonic(tx, &splits.boundaries, &charset, &collation).await?
638 }
639 _ => false,
641 };
642 if !ok {
643 return Err(TransientError::Generic(anyhow::anyhow!(
644 "collation of {table} changed during snapshot setup"
645 )));
646 }
647 }
648 Ok(())
649}
650
651fn is_plain_ident(s: &str) -> bool {
654 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
655}
656
657fn plan_worker_reads(
659 config: &RawSourceCreationConfig,
660 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
661 pk_bounds: &BTreeMap<MySqlTableName, Option<PkBoundaries>>,
662) -> BTreeMap<MySqlTableName, ReadPlan> {
663 tables
664 .keys()
665 .filter_map(|table| {
666 let plan = match pk_bounds.get(table) {
667 Some(Some(splits)) => worker_pk_range(
668 splits,
669 config.worker_id,
670 config.responsible_worker(table),
671 config.worker_count,
672 )
673 .map(ReadPlan::Range),
674 Some(None) => config
675 .responsible_for(table)
676 .then_some(ReadPlan::WholeTable),
677 None => panic!(
678 "Programmer error: tables absent from pk_bounds failed schema \
679 verification and are dropped before planning."
680 ),
681 };
682 plan.map(|plan| (table.clone(), plan))
683 })
684 .collect()
685}
686
687pub(crate) fn render<'scope>(
689 scope: Scope<'scope, GtidPartition>,
690 config: RawSourceCreationConfig,
691 connection: MySqlSourceConnection,
692 source_outputs: Vec<SourceOutputInfo>,
693 metrics: MySqlSnapshotMetrics,
694) -> (
695 Vec<StackedCollection<'scope, GtidPartition, Result<SourceMessage, DataflowError>>>,
696 StreamVec<'scope, GtidPartition, RewindRequest>,
697 StreamVec<'scope, GtidPartition, ReplicationError>,
698 PressOnDropButton,
699) {
700 let mut builder =
701 AsyncOperatorBuilder::new(format!("MySqlSnapshotReader({})", config.id), scope.clone());
702
703 let (feedback_handle, feedback_data) = scope.feedback(Default::default());
704
705 let export_count = config.source_exports.len();
710 let mut raw_handles = Vec::with_capacity(export_count);
711 let mut raw_streams = Vec::with_capacity(export_count);
712 for _ in 0..export_count {
713 let (handle, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
714 raw_handles.push(handle);
715 raw_streams.push(stream);
716 }
717 let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
718 let (definite_error_handle, definite_errors) =
720 builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
721 let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
722
723 let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
727
728 snapshot.broadcast().connect_loop(feedback_handle);
730
731 let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
732
733 let mut all_outputs = vec![];
735 let mut reader_snapshot_table_info = BTreeMap::new();
738 let mut export_statistics = BTreeMap::new();
741 for output in source_outputs.into_iter() {
742 if *output.resume_upper != [GtidPartition::minimum()] {
744 continue;
746 }
747 all_outputs.push(output.output_index);
748 let export_stats = config
749 .statistics
750 .get(&output.export_id)
751 .expect("statistics have been intialized")
752 .clone();
753 export_statistics
754 .entry(output.table_name.clone())
755 .or_insert_with(Vec::new)
756 .push(export_stats);
757
758 reader_snapshot_table_info
759 .entry(output.table_name.clone())
760 .or_insert_with(Vec::new)
761 .push(output);
762 }
763
764 let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD
769 .get(config.config.config_set())
770 .is_zero();
771
772 let (button, transient_errors): (_, StreamVec<'scope, GtidPartition, Rc<TransientError>>) =
773 builder.build_fallible(move |caps| {
774 let busy_signal = Arc::clone(&config.busy_signal);
775 Box::pin(SignaledFuture::new(busy_signal, async move {
776 let (data_cap_sets, caps) = caps.split_at_mut(export_count);
777 let [rewind_cap_set, definite_error_cap_set, snapshot_cap_set]: &mut [_; 3] =
778 caps.try_into().unwrap();
779 let mut raw_handles = SharedFuel::new(raw_handles);
780
781 let id = config.id;
782 let worker_id = config.worker_id;
783
784 if concurrent_replication {
789 release_data_ports(data_cap_sets, &all_outputs.iter().copied().collect());
790 }
791
792 if !all_outputs.is_empty() {
793 for statistics in config.statistics.values() {
797 statistics.set_snapshot_records_known(0);
798 statistics.set_snapshot_records_staged(0);
799 }
800 }
801
802 if reader_snapshot_table_info.is_empty() {
804 trace!(%id, "timely-{worker_id} initializing table reader \
805 with no tables to snapshot, exiting");
806 return Ok(());
807 } else {
808 trace!(%id, "timely-{worker_id} initializing table reader \
809 with {} tables to snapshot",
810 reader_snapshot_table_info.len());
811 }
812
813 let connection_config = connection
814 .connection
815 .config(
816 &config.config.connection_context.secrets_reader,
817 &config.config,
818 InTask::Yes,
819 )
820 .await?;
821 let task_name = format!("timely-{worker_id} MySQL snapshotter");
822
823 let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
826
827 let mut conn = connection_config
828 .connect(
829 &task_name,
830 &config.config.connection_context.ssh_tunnel_manager,
831 )
832 .await?;
833
834 match validate_mysql_repl_settings(&mut conn).await {
836 Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
837 return Ok(return_definite_error(
838 DefiniteError::ServerConfigurationError(err.to_string()),
839 &all_outputs,
840 &mut raw_handles,
841 data_cap_sets,
842 &definite_error_handle,
843 definite_error_cap_set,
844 )
845 .await);
846 }
847 Err(err) => Err(err)?,
848 Ok(()) => (),
849 };
850
851 if let Some(timeout) = config
852 .config
853 .parameters
854 .mysql_source_timeouts
855 .snapshot_wait_timeout
856 {
857 set_wait_timeout(&mut *conn, timeout).await?;
858 }
859
860 let mut lock_conn = if is_snapshot_leader {
861 match lock_and_prepare_snapshot(
862 &config,
863 &connection_config,
864 &task_name,
865 &reader_snapshot_table_info,
866 &metrics,
867 )
868 .await
869 {
870 Ok((info, counts, conn)) => {
871 snapshot_counts = counts;
872 trace!(%id, "timely-{worker_id} broadcasting snapshot info: {info:?}");
873 snapshot_handle.give(&snapshot_cap_set[0], Some(info));
874 Some(conn)
875 }
876 Err(err) => {
877 snapshot_handle.give(&snapshot_cap_set[0], None);
880 return Err(err);
881 }
882 }
883 } else {
884 None
885 };
886
887 let snapshot_info: Option<SnapshotInfo> = loop {
889 match snapshot_input.next().await {
890 Some(AsyncEvent::Data(_, mut data)) => {
891 if let Some(msg) = data.pop() {
892 break msg;
893 }
894 }
895 Some(AsyncEvent::Progress(_)) => continue,
896 None => break None,
899 }
900 };
901 let snapshot_info = match snapshot_info {
902 Some(info) => info,
903 None => return Ok(()),
904 };
905
906 let errored: BTreeMap<usize, DefiniteError> =
907 snapshot_info.errored_outputs.iter().cloned().collect();
908 let errored_outputs: Vec<_> = reader_snapshot_table_info
909 .values()
910 .flatten()
911 .filter_map(|output| {
912 errored.get(&output.output_index).map(|err| (output, err))
913 })
914 .collect();
915 let mut removed_outputs = BTreeSet::new();
916 for (output, err) in errored_outputs {
917 removed_outputs.insert(output.output_index);
918 if !config.responsible_for(&output.table_name) {
921 continue;
922 }
923 let output_index = output.output_index;
924 let update = (Err(err.clone().into()), GtidPartition::minimum(), Diff::ONE);
925 raw_handles
926 .give(output_index, &data_cap_sets[output_index][0], update)
927 .await;
928 tracing::warn!(%id, "timely-{worker_id} stopping snapshot of output {output:?} \
929 due to schema mismatch");
930 }
931 for (_, outputs) in reader_snapshot_table_info.iter_mut() {
932 outputs.retain(|output| !removed_outputs.contains(&output.output_index));
933 }
934 reader_snapshot_table_info.retain(|_, outputs| !outputs.is_empty());
935
936 let snapshot_gtid_frontier = match gtid_set_frontier(&snapshot_info.gtid_set) {
937 Ok(frontier) => frontier,
938 Err(err) => {
939 return Ok(return_definite_error(
942 DefiniteError::UnsupportedGtidState(err.to_string()),
943 &all_outputs,
944 &mut raw_handles,
945 data_cap_sets,
946 &definite_error_handle,
947 definite_error_cap_set,
948 )
949 .await);
950 }
951 };
952
953 trace!(%id, "timely-{worker_id} received snapshot info at: {}",
954 snapshot_gtid_frontier.pretty());
955
956 let emit_rewinds = |rewind_cap_set: &mut CapabilitySet<GtidPartition>| {
959 for (table, outputs) in &reader_snapshot_table_info {
960 if !config.responsible_for(table) {
961 continue;
962 }
963 for output in outputs {
964 trace!(%id, "timely-{worker_id} producing rewind request for {table}\
965 output {}", output.output_index);
966 let req = RewindRequest {
967 output_index: output.output_index,
968 snapshot_upper: snapshot_gtid_frontier.clone(),
969 };
970 rewinds_handle.give(&rewind_cap_set[0], req);
971 }
972 }
973 *rewind_cap_set = CapabilitySet::new();
974 };
975
976 if concurrent_replication {
983 emit_rewinds(rewind_cap_set);
984 }
985
986 let table_ranges = plan_worker_reads(
987 &config,
988 &reader_snapshot_table_info,
989 &snapshot_info.pk_bounds,
990 );
991 let has_work = !table_ranges.is_empty();
992
993 if concurrent_replication {
997 let reading = reader_snapshot_table_info
998 .iter()
999 .filter(|(table, _)| table_ranges.contains_key(*table))
1000 .flat_map(|(_, outputs)| outputs.iter().map(|output| output.output_index))
1001 .collect();
1002 release_data_ports(data_cap_sets, &reading);
1003 }
1004
1005 if !has_work && !is_snapshot_leader {
1007 trace!(%id, "timely-{worker_id} has no tables to snapshot.");
1008 return Ok(());
1009 }
1010
1011 trace!(%id, "timely-{worker_id} starting transaction with \
1012 consistent snapshot at: {}", snapshot_gtid_frontier.pretty());
1013
1014 let mut tx_opts = TxOpts::default();
1017 tx_opts
1018 .with_isolation_level(IsolationLevel::RepeatableRead)
1019 .with_consistent_snapshot(true)
1020 .with_readonly(true);
1021 let mut tx = conn.start_transaction(tx_opts).await?;
1022 #[allow(clippy::disallowed_methods)] tx.query_drop("set @@session.time_zone = '+00:00'").await?;
1028
1029 if let Some(timeout) = config
1033 .config
1034 .parameters
1035 .mysql_source_timeouts
1036 .snapshot_max_execution_time
1037 {
1038 #[allow(clippy::disallowed_methods)]
1040 tx.query_drop(format!(
1041 "SET @@session.max_execution_time = {}",
1042 timeout.as_millis()
1043 ))
1044 .await?;
1045 }
1046
1047 *snapshot_cap_set = CapabilitySet::new();
1050 if is_snapshot_leader {
1051 while snapshot_input.next().await.is_some() {}
1052 if let Some(mut lc) = lock_conn.take() {
1053 #[allow(clippy::disallowed_methods)] lc.query_drop("UNLOCK TABLES").await?;
1055 lc.disconnect().await?;
1056 }
1057 }
1058 drop(lock_conn);
1059
1060 trace!(%id, "timely-{worker_id} started transaction (has_work={has_work}, is_snapshot_leader={is_snapshot_leader})");
1061
1062 let errored_outputs = verify_schemas(
1064 &mut tx,
1065 reader_snapshot_table_info
1066 .iter()
1067 .filter(|(t, _)| table_ranges.contains_key(t))
1068 .map(|(k, v)| (k, v.as_slice()))
1069 .collect(),
1070 )
1071 .await?;
1072 if let Some((output, err)) = errored_outputs.into_iter().next() {
1073 return Err(TransientError::Generic(anyhow::anyhow!(
1074 "schema of {} changed during snapshot setup: {err}",
1075 output.table_name
1076 )));
1077 }
1078 verify_pk_bounds_monotonic(
1079 &mut tx,
1080 &reader_snapshot_table_info,
1081 &table_ranges,
1082 &snapshot_info.pk_bounds,
1083 )
1084 .await?;
1085
1086 if is_snapshot_leader {
1090 publish_snapshot_size(
1091 &snapshot_counts,
1092 &reader_snapshot_table_info,
1093 &export_statistics,
1094 );
1095 }
1096
1097 if reader_snapshot_table_info.is_empty() {
1099 return Ok(());
1100 }
1101
1102 let mut final_row = Row::default();
1104
1105 let mut snapshot_staged_total = 0;
1106 for (table, outputs) in &reader_snapshot_table_info {
1107 let pk_range = match table_ranges.get(table) {
1108 Some(ReadPlan::Range(range)) => Some(range),
1109 Some(ReadPlan::WholeTable) => None,
1110 None => continue,
1112 };
1113
1114 mysql_snapshot_failpoint_pause(&table.1).await;
1115
1116 let mut snapshot_staged = 0;
1117 let (query, params) = build_snapshot_query(outputs, pk_range);
1118 trace!(%id, "timely-{worker_id} reading snapshot query='{}'", query);
1119 let mut results = tx.exec_stream(query, params).await?;
1120 while let Some(row) = results.try_next().await? {
1121 let row: MySqlRow = row;
1122 snapshot_staged += 1;
1123 for (output, row_val) in outputs.iter().repeat_clone(row) {
1124 let event = match pack_mysql_row(
1128 &mut final_row,
1129 row_val,
1130 &output.desc,
1131 None,
1132 output.binlog_full_metadata,
1133 ) {
1134 Ok(row) => Ok(SourceMessage {
1135 key: Row::default(),
1136 value: row,
1137 metadata: Row::default(),
1138 }),
1139 Err(err @ MySqlError::ValueDecodeError { .. }) => {
1141 Err(DataflowError::from(DefiniteError::ValueDecodeError(
1142 err.to_string(),
1143 )))
1144 }
1145 Err(err) => Err(err)?,
1146 };
1147 let output_index = output.output_index;
1148 let update = (event, GtidPartition::minimum(), Diff::ONE);
1149 raw_handles
1150 .give(output_index, &data_cap_sets[output_index][0], update)
1151 .await;
1152 }
1153 snapshot_staged_total += u64::cast_from(outputs.len());
1155 if snapshot_staged_total % 1000 == 0 {
1156 for statistics in export_statistics.get(table).unwrap() {
1157 statistics.set_snapshot_records_staged(snapshot_staged);
1158 }
1159 }
1160 }
1161 for statistics in export_statistics.get(table).unwrap() {
1162 statistics.set_snapshot_records_staged(snapshot_staged);
1163 }
1164 trace!(%id, "timely-{worker_id} snapshotted {} records from \
1165 table '{table}'", snapshot_staged * u64::cast_from(outputs.len()));
1166 if concurrent_replication {
1169 for output in outputs {
1170 data_cap_sets[output.output_index] = CapabilitySet::new();
1171 }
1172 }
1173 }
1174
1175 if !concurrent_replication {
1176 emit_rewinds(rewind_cap_set);
1177 }
1178
1179 Ok(())
1180 }))
1181 });
1182
1183 let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
1186
1187 (
1188 raw_streams
1189 .into_iter()
1190 .map(|stream| stream.as_collection())
1191 .collect(),
1192 rewinds,
1193 errors,
1194 button.press_on_drop(),
1195 )
1196}
1197
1198async fn mysql_snapshot_failpoint_pause(table: &str) {
1203 let held_since = std::time::Instant::now();
1204 loop {
1205 let held = fail::eval("mysql_snapshot_pause", |payload| {
1206 let (paused_table, millis) = payload
1207 .as_deref()
1208 .map(|p| p.split_once(':').unwrap_or((p, "")))
1209 .unwrap_or_default();
1210 paused_table == table
1211 && millis.parse::<u64>().map_or(true, |millis| {
1212 held_since.elapsed() < Duration::from_millis(millis)
1213 })
1214 });
1215 match held {
1216 Some(true) => tokio::time::sleep(Duration::from_millis(100)).await,
1217 _ => break,
1218 }
1219 }
1220}
1221
1222fn release_data_ports(data_cap_sets: &mut [CapabilitySet<GtidPartition>], keep: &BTreeSet<usize>) {
1224 for (output_index, cap_set) in data_cap_sets.iter_mut().enumerate() {
1225 if !keep.contains(&output_index) {
1226 *cap_set = CapabilitySet::new();
1227 }
1228 }
1229}
1230
1231fn publish_snapshot_size(
1235 counts: &BTreeMap<MySqlTableName, u64>,
1236 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
1237 export_statistics: &BTreeMap<MySqlTableName, Vec<SourceStatistics>>,
1238) {
1239 for name in tables.keys() {
1240 let count = counts.get(name).copied().unwrap_or(0);
1241 let stats = export_statistics
1242 .get(name)
1243 .expect("statistics are initialized for each output");
1244 for export_stat in stats {
1245 export_stat.set_snapshot_records_known(count);
1246 export_stat.set_snapshot_records_staged(0);
1247 }
1248 }
1249}
1250
1251async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1254where
1255 Q: Queryable,
1256{
1257 #[allow(clippy::disallowed_methods)]
1259 conn.query_drop(format!(
1260 "SET @@session.wait_timeout = {}",
1261 timeout.as_secs()
1262 ))
1263 .await
1264}
1265async fn lock_tables_and_read_gtid_set(
1266 lock_conn: &mut MySqlConn,
1267 lock_clauses: &str,
1268 lock_wait_timeout: Option<Duration>,
1269) -> Result<String, TransientError> {
1270 if let Some(timeout) = lock_wait_timeout {
1271 #[allow(clippy::disallowed_methods)]
1273 lock_conn
1274 .query_drop(format!(
1275 "SET @@session.lock_wait_timeout = {}",
1276 timeout.as_secs()
1277 ))
1278 .await?;
1279 }
1280
1281 if !lock_clauses.is_empty() {
1284 #[allow(clippy::disallowed_methods)]
1285 lock_conn
1286 .query_drop(format!("LOCK TABLES {lock_clauses}"))
1287 .await?;
1288 }
1289
1290 let snapshot_gtid_set = query_sys_var(lock_conn, "global.gtid_executed").await?;
1291 Ok(snapshot_gtid_set)
1292}
1293
1294#[must_use]
1301fn build_snapshot_query(
1302 outputs: &[SourceOutputInfo],
1303 pk_range: Option<&PkRange>,
1304) -> (String, Vec<Value>) {
1305 let info = outputs.first().expect("MySQL table info");
1306 for output in &outputs[1..] {
1307 assert!(
1310 info.desc.columns.len() == output.desc.columns.len(),
1311 "Mismatch in table descriptions for {}",
1312 info.table_name
1313 );
1314 }
1315 let columns = info
1316 .desc
1317 .columns
1318 .iter()
1319 .map(|col| quote_identifier(&col.name))
1320 .join(", ");
1321 let mut query = format!("SELECT {} FROM {}", columns, info.table_name);
1322 let mut params: Vec<Value> = vec![];
1323 if let Some(range) = pk_range {
1324 let col = &range.pk_col;
1327 if let Some(lower) = &range.lower {
1328 query.push_str(&format!(" WHERE {col} >= ?"));
1329 params.push(lower.as_str().into());
1330 }
1331 if let Some(upper) = &range.upper {
1332 let kw = if range.lower.is_some() {
1333 "AND"
1334 } else {
1335 "WHERE"
1336 };
1337 query.push_str(&format!(" {kw} {col} < ?"));
1338 params.push(upper.as_str().into());
1339 }
1340 }
1341 (query, params)
1342}
1343
1344#[derive(Default)]
1345struct TableStatistics {
1346 count_latency: f64,
1347 count: u64,
1348}
1349
1350async fn collect_table_statistics<Q>(
1355 conn: &mut Q,
1356 table: &MySqlTableName,
1357 exact_count_max_rows: u64,
1358) -> Result<TableStatistics, TransientError>
1359where
1360 Q: Queryable,
1361{
1362 let mut stats = TableStatistics::default();
1363
1364 let estimate: Option<Option<u64>> = conn
1369 .exec_first(
1370 "SELECT table_rows FROM information_schema.tables \
1371 WHERE table_schema = ? AND table_name = ?",
1372 (&table.0, &table.1),
1373 )
1374 .wall_time()
1375 .set_at(&mut stats.count_latency)
1376 .await?;
1377 match estimate.flatten() {
1378 Some(estimate) if estimate > exact_count_max_rows => {
1379 stats.count = estimate;
1380 }
1381 _ => {
1382 #[allow(clippy::disallowed_methods)]
1385 let count_row: Option<u64> = conn
1386 .query_first(format!("SELECT COUNT(*) FROM {}", table))
1387 .wall_time()
1388 .set_at(&mut stats.count_latency)
1389 .await?;
1390 stats.count = count_row.unwrap_or(0);
1394 }
1395 }
1396
1397 Ok(stats)
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403 use mz_mysql_util::{MySqlColumnDesc, MySqlTableDesc};
1404 use timely::progress::Antichain;
1405
1406 #[mz_ore::test]
1407 fn snapshot_query_duplicate_table() {
1408 let schema_name = "myschema".to_string();
1409 let table_name = "mytable".to_string();
1410 let table = MySqlTableName(schema_name.clone(), table_name.clone());
1411 let columns = ["c1", "c2", "c3"]
1412 .iter()
1413 .map(|col| MySqlColumnDesc {
1414 name: col.to_string(),
1415 column_type: None,
1416 meta: None,
1417 })
1418 .collect::<Vec<_>>();
1419 let desc = MySqlTableDesc {
1420 schema_name: schema_name.clone(),
1421 name: table_name.clone(),
1422 columns,
1423 keys: BTreeSet::default(),
1424 };
1425 let info = SourceOutputInfo {
1426 output_index: 1, table_name: table.clone(),
1428 desc,
1429 text_columns: vec![],
1430 exclude_columns: vec![],
1431 initial_gtid_set: Antichain::default(),
1432 resume_upper: Antichain::default(),
1433 export_id: mz_repr::GlobalId::User(1),
1434 binlog_full_metadata: false,
1435 };
1436 let (query, _) = build_snapshot_query(&[info.clone(), info], None);
1437 assert_eq!(
1438 format!(
1439 "SELECT `c1`, `c2`, `c3` FROM `{}`.`{}`",
1440 schema_name, table_name
1441 ),
1442 query
1443 );
1444 }
1445
1446 #[mz_ore::test]
1447 fn snapshot_query_with_pk_range() {
1448 let schema_name = "myschema".to_string();
1449 let table_name = "mytable".to_string();
1450 let table = MySqlTableName(schema_name.clone(), table_name.clone());
1451 let columns = ["id", "name"]
1452 .iter()
1453 .map(|col| MySqlColumnDesc {
1454 name: col.to_string(),
1455 column_type: None,
1456 meta: None,
1457 })
1458 .collect::<Vec<_>>();
1459 let desc = MySqlTableDesc {
1460 schema_name: schema_name.clone(),
1461 name: table_name.clone(),
1462 columns,
1463 keys: BTreeSet::default(),
1464 };
1465 let info = SourceOutputInfo {
1466 output_index: 1,
1467 table_name: table.clone(),
1468 desc,
1469 text_columns: vec![],
1470 exclude_columns: vec![],
1471 initial_gtid_set: Antichain::default(),
1472 resume_upper: Antichain::default(),
1473 export_id: mz_repr::GlobalId::User(1),
1474 binlog_full_metadata: false,
1475 };
1476
1477 let range = PkRange {
1479 pk_col: "`id`".to_string(),
1480 lower: Some("100".to_string()),
1481 upper: Some("200".to_string()),
1482 };
1483 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1484 assert_eq!(
1485 format!(
1486 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ? AND `id` < ?",
1487 schema_name, table_name
1488 ),
1489 query
1490 );
1491 assert_eq!(params, vec![Value::from("100"), Value::from("200")]);
1492
1493 let range = PkRange {
1495 pk_col: "`id`".to_string(),
1496 lower: None,
1497 upper: Some("200".to_string()),
1498 };
1499 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1500 assert_eq!(
1501 format!(
1502 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` < ?",
1503 schema_name, table_name
1504 ),
1505 query
1506 );
1507 assert_eq!(params, vec![Value::from("200")]);
1508
1509 let range = PkRange {
1511 pk_col: "`id`".to_string(),
1512 lower: Some("200".to_string()),
1513 upper: None,
1514 };
1515 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1516 assert_eq!(
1517 format!(
1518 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ?",
1519 schema_name, table_name
1520 ),
1521 query
1522 );
1523 assert_eq!(params, vec![Value::from("200")]);
1524 }
1525
1526 #[mz_ore::test]
1527 fn test_worker_pk_range() {
1528 let splits = PkBoundaries {
1531 pk_col: "`id`".to_string(),
1532 boundaries: vec!["51".to_string()],
1533 };
1534 let r0 = worker_pk_range(&splits, 0, 0, 4).expect("worker 0");
1535 assert_eq!(r0.pk_col, "`id`");
1536 assert_eq!(r0.lower, None); assert_eq!(r0.upper.as_deref(), Some("51"));
1538 let r1 = worker_pk_range(&splits, 1, 0, 4).expect("worker 1");
1539 assert_eq!(r1.lower.as_deref(), Some("51"));
1540 assert_eq!(r1.upper, None); assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1543
1544 let splits = PkBoundaries {
1546 pk_col: "`id`".to_string(),
1547 boundaries: vec!["34".to_string(), "67".to_string()],
1548 };
1549 let r1 = worker_pk_range(&splits, 1, 0, 3).expect("worker 1");
1550 assert_eq!(r1.lower.as_deref(), Some("34"));
1551 assert_eq!(r1.upper.as_deref(), Some("67"));
1552
1553 let owner = 2;
1556 let owned = worker_pk_range(&splits, owner, owner, 3).expect("owner has work");
1557 assert_eq!(owned.lower, None);
1558 let mut ranges: Vec<_> = (0..3)
1559 .map(|w| {
1560 let r = worker_pk_range(&splits, w, owner, 3).expect("worker has work");
1561 (r.lower, r.upper)
1562 })
1563 .collect();
1564 ranges.sort();
1565 assert_eq!(
1566 ranges,
1567 vec![
1568 (None, Some("34".to_string())),
1569 (Some("34".to_string()), Some("67".to_string())),
1570 (Some("67".to_string()), None),
1571 ]
1572 );
1573 }
1574
1575 #[mz_ore::test]
1576 fn test_single_column_pk() {
1577 use mz_mysql_util::MySqlKeyDesc;
1578 use mz_repr::SqlColumnType;
1579
1580 let col = |name: &str, ty: SqlScalarType| MySqlColumnDesc {
1581 name: name.to_string(),
1582 column_type: Some(SqlColumnType {
1583 scalar_type: ty,
1584 nullable: false,
1585 }),
1586 meta: None,
1587 };
1588 let pk = |cols: &[&str]| {
1589 BTreeSet::from([MySqlKeyDesc {
1590 name: "PRIMARY".to_string(),
1591 is_primary: true,
1592 columns: cols.iter().map(|c| c.to_string()).collect(),
1593 }])
1594 };
1595 let desc = |columns, keys| MySqlTableDesc {
1596 schema_name: "s".to_string(),
1597 name: "t".to_string(),
1598 columns,
1599 keys,
1600 };
1601
1602 let (name, ty) = try_extract_single_column_pk(&desc(
1604 vec![col("id", SqlScalarType::Char { length: None })],
1605 pk(&["id"]),
1606 ))
1607 .expect("single-column pk");
1608 assert_eq!(name, "id");
1609 assert!(matches!(ty, SqlScalarType::Char { .. }));
1610
1611 let (name, ty) =
1612 try_extract_single_column_pk(&desc(vec![col("id", SqlScalarType::Bytes)], pk(&["id"])))
1613 .expect("single-column pk");
1614 assert_eq!(name, "id");
1615 assert!(matches!(ty, SqlScalarType::Bytes));
1616
1617 assert!(
1619 try_extract_single_column_pk(&desc(
1620 vec![
1621 col("a", SqlScalarType::Char { length: None }),
1622 col("b", SqlScalarType::Int64),
1623 ],
1624 pk(&["a", "b"]),
1625 ))
1626 .is_none()
1627 );
1628
1629 assert!(
1631 try_extract_single_column_pk(&desc(
1632 vec![col("id", SqlScalarType::Int64)],
1633 BTreeSet::default()
1634 ))
1635 .is_none()
1636 );
1637 }
1638}