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, 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
153fn 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 boundaries: Vec<String>,
176}
177
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
179struct SnapshotInfo {
180 gtid_set: String,
181 pk_bounds: BTreeMap<MySqlTableName, Option<PkBoundaries>>,
183 errored_outputs: Vec<(usize, DefiniteError)>,
184}
185
186struct PkRange {
187 pk_col: String,
189 lower: Option<String>,
191 upper: Option<String>,
193}
194
195enum ReadPlan {
197 Range(PkRange),
199 WholeTable,
201}
202
203fn 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
223async 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 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 #[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 let Some(mut row) = row else { break };
284 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
301async 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 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 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 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 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 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 .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 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
423async 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 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 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
522async 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 Ok(row.and_then(|(charset, collation)| Some((charset?, collation?))))
542}
543
544async 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 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 #[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
616fn 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
627fn 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
657pub(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 let (definite_error_handle, definite_errors) =
679 builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
680 let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
681
682 let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
686
687 snapshot.broadcast().connect_loop(feedback_handle);
689
690 let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
691
692 let mut all_outputs = vec![];
694 let mut reader_snapshot_table_info = BTreeMap::new();
697 let mut export_statistics = BTreeMap::new();
700 for output in source_outputs.into_iter() {
701 if *output.resume_upper != [GtidPartition::minimum()] {
703 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 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 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 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 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 snapshot_handle.give(&snapshot_cap_set[0], None);
825 return Err(err);
826 }
827 }
828 } else {
829 None
830 };
831
832 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 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 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 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 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 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 #[allow(clippy::disallowed_methods)] tx.query_drop("set @@session.time_zone = '+00:00'").await?;
933
934 if let Some(timeout) = config
938 .config
939 .parameters
940 .mysql_source_timeouts
941 .snapshot_max_execution_time
942 {
943 #[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 *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)] 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 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 if is_snapshot_leader {
995 publish_snapshot_size(
996 &snapshot_counts,
997 &reader_snapshot_table_info,
998 &export_statistics,
999 );
1000 }
1001
1002 if reader_snapshot_table_info.is_empty() {
1004 return Ok(());
1005 }
1006
1007 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 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 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 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 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 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 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
1111fn 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
1131async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1134where
1135 Q: Queryable,
1136{
1137 #[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 #[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 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#[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 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 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
1224async 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 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 #[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 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, 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 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 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 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 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); 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); assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1414
1415 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 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 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 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 assert!(
1502 try_extract_single_column_pk(&desc(
1503 vec![col("id", SqlScalarType::Int64)],
1504 BTreeSet::default()
1505 ))
1506 .is_none()
1507 );
1508 }
1509}