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::errors::DataflowError;
128use mz_storage_types::sources::MySqlSourceConnection;
129use mz_storage_types::sources::mysql::{GtidPartition, gtid_set_frontier};
130use mz_timely_util::antichain::AntichainExt;
131use mz_timely_util::builder_async::{
132 Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
133};
134use mz_timely_util::containers::stack::FueledBuilder;
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::{FuelSize, 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 StackedCollection<'scope, GtidPartition, (usize, 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 (raw_handle, raw_data) = builder.new_output::<FueledBuilder<_>>();
706 let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
707 let (definite_error_handle, definite_errors) =
709 builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
710 let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
711
712 let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
716
717 snapshot.broadcast().connect_loop(feedback_handle);
719
720 let is_snapshot_leader = config.responsible_for("mysql_snapshot_leader");
721
722 let mut all_outputs = vec![];
724 let mut reader_snapshot_table_info = BTreeMap::new();
727 let mut export_statistics = BTreeMap::new();
730 for output in source_outputs.into_iter() {
731 if *output.resume_upper != [GtidPartition::minimum()] {
733 continue;
735 }
736 all_outputs.push(output.output_index);
737 let export_stats = config
738 .statistics
739 .get(&output.export_id)
740 .expect("statistics have been intialized")
741 .clone();
742 export_statistics
743 .entry(output.table_name.clone())
744 .or_insert_with(Vec::new)
745 .push(export_stats);
746
747 reader_snapshot_table_info
748 .entry(output.table_name.clone())
749 .or_insert_with(Vec::new)
750 .push(output);
751 }
752
753 let (button, transient_errors): (_, StreamVec<'scope, GtidPartition, Rc<TransientError>>) =
754 builder.build_fallible(move |caps| {
755 let busy_signal = Arc::clone(&config.busy_signal);
756 Box::pin(SignaledFuture::new(busy_signal, async move {
757 let [
758 data_cap_set,
759 rewind_cap_set,
760 definite_error_cap_set,
761 snapshot_cap_set,
762 ]: &mut [_; 4] = caps.try_into().unwrap();
763
764 let id = config.id;
765 let worker_id = config.worker_id;
766
767 if !all_outputs.is_empty() {
768 for statistics in config.statistics.values() {
772 statistics.set_snapshot_records_known(0);
773 statistics.set_snapshot_records_staged(0);
774 }
775 }
776
777 if reader_snapshot_table_info.is_empty() {
779 trace!(%id, "timely-{worker_id} initializing table reader \
780 with no tables to snapshot, exiting");
781 return Ok(());
782 } else {
783 trace!(%id, "timely-{worker_id} initializing table reader \
784 with {} tables to snapshot",
785 reader_snapshot_table_info.len());
786 }
787
788 let connection_config = connection
789 .connection
790 .config(
791 &config.config.connection_context.secrets_reader,
792 &config.config,
793 InTask::Yes,
794 )
795 .await?;
796 let task_name = format!("timely-{worker_id} MySQL snapshotter");
797
798 let mut snapshot_counts: BTreeMap<MySqlTableName, u64> = BTreeMap::new();
801
802 let mut conn = connection_config
803 .connect(
804 &task_name,
805 &config.config.connection_context.ssh_tunnel_manager,
806 )
807 .await?;
808
809 match validate_mysql_repl_settings(&mut conn).await {
811 Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
812 return Ok(return_definite_error(
813 DefiniteError::ServerConfigurationError(err.to_string()),
814 &all_outputs,
815 &raw_handle,
816 data_cap_set,
817 &definite_error_handle,
818 definite_error_cap_set,
819 )
820 .await);
821 }
822 Err(err) => Err(err)?,
823 Ok(()) => (),
824 };
825
826 if let Some(timeout) = config
827 .config
828 .parameters
829 .mysql_source_timeouts
830 .snapshot_wait_timeout
831 {
832 set_wait_timeout(&mut *conn, timeout).await?;
833 }
834
835 let mut lock_conn = if is_snapshot_leader {
836 match lock_and_prepare_snapshot(
837 &config,
838 &connection_config,
839 &task_name,
840 &reader_snapshot_table_info,
841 &metrics,
842 )
843 .await
844 {
845 Ok((info, counts, conn)) => {
846 snapshot_counts = counts;
847 trace!(%id, "timely-{worker_id} broadcasting snapshot info: {info:?}");
848 snapshot_handle.give(&snapshot_cap_set[0], Some(info));
849 Some(conn)
850 }
851 Err(err) => {
852 snapshot_handle.give(&snapshot_cap_set[0], None);
855 return Err(err);
856 }
857 }
858 } else {
859 None
860 };
861
862 let snapshot_info: Option<SnapshotInfo> = loop {
864 match snapshot_input.next().await {
865 Some(AsyncEvent::Data(_, mut data)) => {
866 if let Some(msg) = data.pop() {
867 break msg;
868 }
869 }
870 Some(AsyncEvent::Progress(_)) => continue,
871 None => break None,
874 }
875 };
876 let snapshot_info = match snapshot_info {
877 Some(info) => info,
878 None => return Ok(()),
879 };
880
881 let errored: BTreeMap<usize, DefiniteError> =
882 snapshot_info.errored_outputs.iter().cloned().collect();
883 let errored_outputs: Vec<_> = reader_snapshot_table_info
884 .values()
885 .flatten()
886 .filter_map(|output| {
887 errored.get(&output.output_index).map(|err| (output, err))
888 })
889 .collect();
890 let mut removed_outputs = BTreeSet::new();
891 for (output, err) in errored_outputs {
892 removed_outputs.insert(output.output_index);
893 if !config.responsible_for(&output.table_name) {
896 continue;
897 }
898 let update = (
899 (output.output_index, Err(err.clone().into())),
900 GtidPartition::minimum(),
901 Diff::ONE,
902 );
903 let size = update.fuel_size();
904 raw_handle.give_fueled(&data_cap_set[0], update, size).await;
905 tracing::warn!(%id, "timely-{worker_id} stopping snapshot of output {output:?} \
906 due to schema mismatch");
907 }
908 for (_, outputs) in reader_snapshot_table_info.iter_mut() {
909 outputs.retain(|output| !removed_outputs.contains(&output.output_index));
910 }
911 reader_snapshot_table_info.retain(|_, outputs| !outputs.is_empty());
912
913 let snapshot_gtid_frontier = match gtid_set_frontier(&snapshot_info.gtid_set) {
914 Ok(frontier) => frontier,
915 Err(err) => {
916 return Ok(return_definite_error(
919 DefiniteError::UnsupportedGtidState(err.to_string()),
920 &all_outputs,
921 &raw_handle,
922 data_cap_set,
923 &definite_error_handle,
924 definite_error_cap_set,
925 )
926 .await);
927 }
928 };
929
930 trace!(%id, "timely-{worker_id} received snapshot info at: {}",
931 snapshot_gtid_frontier.pretty());
932
933 let table_ranges = plan_worker_reads(
934 &config,
935 &reader_snapshot_table_info,
936 &snapshot_info.pk_bounds,
937 );
938 let has_work = !table_ranges.is_empty();
939
940 if !has_work && !is_snapshot_leader {
942 trace!(%id, "timely-{worker_id} has no tables to snapshot.");
943 return Ok(());
944 }
945
946 trace!(%id, "timely-{worker_id} starting transaction with \
947 consistent snapshot at: {}", snapshot_gtid_frontier.pretty());
948
949 let mut tx_opts = TxOpts::default();
952 tx_opts
953 .with_isolation_level(IsolationLevel::RepeatableRead)
954 .with_consistent_snapshot(true)
955 .with_readonly(true);
956 let mut tx = conn.start_transaction(tx_opts).await?;
957 #[allow(clippy::disallowed_methods)] tx.query_drop("set @@session.time_zone = '+00:00'").await?;
963
964 if let Some(timeout) = config
968 .config
969 .parameters
970 .mysql_source_timeouts
971 .snapshot_max_execution_time
972 {
973 #[allow(clippy::disallowed_methods)]
975 tx.query_drop(format!(
976 "SET @@session.max_execution_time = {}",
977 timeout.as_millis()
978 ))
979 .await?;
980 }
981
982 *snapshot_cap_set = CapabilitySet::new();
985 if is_snapshot_leader {
986 while snapshot_input.next().await.is_some() {}
987 if let Some(mut lc) = lock_conn.take() {
988 #[allow(clippy::disallowed_methods)] lc.query_drop("UNLOCK TABLES").await?;
990 lc.disconnect().await?;
991 }
992 }
993 drop(lock_conn);
994
995 trace!(%id, "timely-{worker_id} started transaction (has_work={has_work}, is_snapshot_leader={is_snapshot_leader})");
996
997 let errored_outputs = verify_schemas(
999 &mut tx,
1000 reader_snapshot_table_info
1001 .iter()
1002 .filter(|(t, _)| table_ranges.contains_key(t))
1003 .map(|(k, v)| (k, v.as_slice()))
1004 .collect(),
1005 )
1006 .await?;
1007 if let Some((output, err)) = errored_outputs.into_iter().next() {
1008 return Err(TransientError::Generic(anyhow::anyhow!(
1009 "schema of {} changed during snapshot setup: {err}",
1010 output.table_name
1011 )));
1012 }
1013 verify_pk_bounds_monotonic(
1014 &mut tx,
1015 &reader_snapshot_table_info,
1016 &table_ranges,
1017 &snapshot_info.pk_bounds,
1018 )
1019 .await?;
1020
1021 if is_snapshot_leader {
1025 publish_snapshot_size(
1026 &snapshot_counts,
1027 &reader_snapshot_table_info,
1028 &export_statistics,
1029 );
1030 }
1031
1032 if reader_snapshot_table_info.is_empty() {
1034 return Ok(());
1035 }
1036
1037 let mut final_row = Row::default();
1039
1040 let mut snapshot_staged_total = 0;
1041 for (table, outputs) in &reader_snapshot_table_info {
1042 let pk_range = match table_ranges.get(table) {
1043 Some(ReadPlan::Range(range)) => Some(range),
1044 Some(ReadPlan::WholeTable) => None,
1045 None => continue,
1047 };
1048
1049 let mut snapshot_staged = 0;
1050 let (query, params) = build_snapshot_query(outputs, pk_range);
1051 trace!(%id, "timely-{worker_id} reading snapshot query='{}'", query);
1052 let mut results = tx.exec_stream(query, params).await?;
1053 while let Some(row) = results.try_next().await? {
1054 let row: MySqlRow = row;
1055 snapshot_staged += 1;
1056 for (output, row_val) in outputs.iter().repeat_clone(row) {
1057 let event = match pack_mysql_row(
1061 &mut final_row,
1062 row_val,
1063 &output.desc,
1064 None,
1065 output.binlog_full_metadata,
1066 ) {
1067 Ok(row) => Ok(SourceMessage {
1068 key: Row::default(),
1069 value: row,
1070 metadata: Row::default(),
1071 }),
1072 Err(err @ MySqlError::ValueDecodeError { .. }) => {
1074 Err(DataflowError::from(DefiniteError::ValueDecodeError(
1075 err.to_string(),
1076 )))
1077 }
1078 Err(err) => Err(err)?,
1079 };
1080 let update = (
1081 (output.output_index, event),
1082 GtidPartition::minimum(),
1083 Diff::ONE,
1084 );
1085 let size = update.fuel_size();
1086 raw_handle.give_fueled(&data_cap_set[0], update, size).await;
1087 }
1088 snapshot_staged_total += u64::cast_from(outputs.len());
1090 if snapshot_staged_total % 1000 == 0 {
1091 for statistics in export_statistics.get(table).unwrap() {
1092 statistics.set_snapshot_records_staged(snapshot_staged);
1093 }
1094 }
1095 }
1096 for statistics in export_statistics.get(table).unwrap() {
1097 statistics.set_snapshot_records_staged(snapshot_staged);
1098 }
1099 trace!(%id, "timely-{worker_id} snapshotted {} records from \
1100 table '{table}'", snapshot_staged * u64::cast_from(outputs.len()));
1101 }
1102
1103 for (table, outputs) in &reader_snapshot_table_info {
1110 if !config.responsible_for(table) {
1111 continue;
1112 }
1113 for output in outputs {
1114 trace!(%id, "timely-{worker_id} producing rewind request for {table}\
1115 output {}", output.output_index);
1116 let req = RewindRequest {
1117 output_index: output.output_index,
1118 snapshot_upper: snapshot_gtid_frontier.clone(),
1119 };
1120 rewinds_handle.give(&rewind_cap_set[0], req);
1121 }
1122 }
1123 *rewind_cap_set = CapabilitySet::new();
1124
1125 Ok(())
1126 }))
1127 });
1128
1129 let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
1132
1133 (
1134 raw_data.as_collection(),
1135 rewinds,
1136 errors,
1137 button.press_on_drop(),
1138 )
1139}
1140
1141fn publish_snapshot_size(
1145 counts: &BTreeMap<MySqlTableName, u64>,
1146 tables: &BTreeMap<MySqlTableName, Vec<SourceOutputInfo>>,
1147 export_statistics: &BTreeMap<MySqlTableName, Vec<SourceStatistics>>,
1148) {
1149 for name in tables.keys() {
1150 let count = counts.get(name).copied().unwrap_or(0);
1151 let stats = export_statistics
1152 .get(name)
1153 .expect("statistics are initialized for each output");
1154 for export_stat in stats {
1155 export_stat.set_snapshot_records_known(count);
1156 export_stat.set_snapshot_records_staged(0);
1157 }
1158 }
1159}
1160
1161async fn set_wait_timeout<Q>(conn: &mut Q, timeout: Duration) -> Result<(), mysql_async::Error>
1164where
1165 Q: Queryable,
1166{
1167 #[allow(clippy::disallowed_methods)]
1169 conn.query_drop(format!(
1170 "SET @@session.wait_timeout = {}",
1171 timeout.as_secs()
1172 ))
1173 .await
1174}
1175async fn lock_tables_and_read_gtid_set(
1176 lock_conn: &mut MySqlConn,
1177 lock_clauses: &str,
1178 lock_wait_timeout: Option<Duration>,
1179) -> Result<String, TransientError> {
1180 if let Some(timeout) = lock_wait_timeout {
1181 #[allow(clippy::disallowed_methods)]
1183 lock_conn
1184 .query_drop(format!(
1185 "SET @@session.lock_wait_timeout = {}",
1186 timeout.as_secs()
1187 ))
1188 .await?;
1189 }
1190
1191 if !lock_clauses.is_empty() {
1194 #[allow(clippy::disallowed_methods)]
1195 lock_conn
1196 .query_drop(format!("LOCK TABLES {lock_clauses}"))
1197 .await?;
1198 }
1199
1200 let snapshot_gtid_set = query_sys_var(lock_conn, "global.gtid_executed").await?;
1201 Ok(snapshot_gtid_set)
1202}
1203
1204#[must_use]
1211fn build_snapshot_query(
1212 outputs: &[SourceOutputInfo],
1213 pk_range: Option<&PkRange>,
1214) -> (String, Vec<Value>) {
1215 let info = outputs.first().expect("MySQL table info");
1216 for output in &outputs[1..] {
1217 assert!(
1220 info.desc.columns.len() == output.desc.columns.len(),
1221 "Mismatch in table descriptions for {}",
1222 info.table_name
1223 );
1224 }
1225 let columns = info
1226 .desc
1227 .columns
1228 .iter()
1229 .map(|col| quote_identifier(&col.name))
1230 .join(", ");
1231 let mut query = format!("SELECT {} FROM {}", columns, info.table_name);
1232 let mut params: Vec<Value> = vec![];
1233 if let Some(range) = pk_range {
1234 let col = &range.pk_col;
1237 if let Some(lower) = &range.lower {
1238 query.push_str(&format!(" WHERE {col} >= ?"));
1239 params.push(lower.as_str().into());
1240 }
1241 if let Some(upper) = &range.upper {
1242 let kw = if range.lower.is_some() {
1243 "AND"
1244 } else {
1245 "WHERE"
1246 };
1247 query.push_str(&format!(" {kw} {col} < ?"));
1248 params.push(upper.as_str().into());
1249 }
1250 }
1251 (query, params)
1252}
1253
1254#[derive(Default)]
1255struct TableStatistics {
1256 count_latency: f64,
1257 count: u64,
1258}
1259
1260async fn collect_table_statistics<Q>(
1265 conn: &mut Q,
1266 table: &MySqlTableName,
1267 exact_count_max_rows: u64,
1268) -> Result<TableStatistics, TransientError>
1269where
1270 Q: Queryable,
1271{
1272 let mut stats = TableStatistics::default();
1273
1274 let estimate: Option<Option<u64>> = conn
1279 .exec_first(
1280 "SELECT table_rows FROM information_schema.tables \
1281 WHERE table_schema = ? AND table_name = ?",
1282 (&table.0, &table.1),
1283 )
1284 .wall_time()
1285 .set_at(&mut stats.count_latency)
1286 .await?;
1287 match estimate.flatten() {
1288 Some(estimate) if estimate > exact_count_max_rows => {
1289 stats.count = estimate;
1290 }
1291 _ => {
1292 #[allow(clippy::disallowed_methods)]
1295 let count_row: Option<u64> = conn
1296 .query_first(format!("SELECT COUNT(*) FROM {}", table))
1297 .wall_time()
1298 .set_at(&mut stats.count_latency)
1299 .await?;
1300 stats.count = count_row.unwrap_or(0);
1304 }
1305 }
1306
1307 Ok(stats)
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313 use mz_mysql_util::{MySqlColumnDesc, MySqlTableDesc};
1314 use timely::progress::Antichain;
1315
1316 #[mz_ore::test]
1317 fn snapshot_query_duplicate_table() {
1318 let schema_name = "myschema".to_string();
1319 let table_name = "mytable".to_string();
1320 let table = MySqlTableName(schema_name.clone(), table_name.clone());
1321 let columns = ["c1", "c2", "c3"]
1322 .iter()
1323 .map(|col| MySqlColumnDesc {
1324 name: col.to_string(),
1325 column_type: None,
1326 meta: None,
1327 })
1328 .collect::<Vec<_>>();
1329 let desc = MySqlTableDesc {
1330 schema_name: schema_name.clone(),
1331 name: table_name.clone(),
1332 columns,
1333 keys: BTreeSet::default(),
1334 };
1335 let info = SourceOutputInfo {
1336 output_index: 1, table_name: table.clone(),
1338 desc,
1339 text_columns: vec![],
1340 exclude_columns: vec![],
1341 initial_gtid_set: Antichain::default(),
1342 resume_upper: Antichain::default(),
1343 export_id: mz_repr::GlobalId::User(1),
1344 binlog_full_metadata: false,
1345 };
1346 let (query, _) = build_snapshot_query(&[info.clone(), info], None);
1347 assert_eq!(
1348 format!(
1349 "SELECT `c1`, `c2`, `c3` FROM `{}`.`{}`",
1350 schema_name, table_name
1351 ),
1352 query
1353 );
1354 }
1355
1356 #[mz_ore::test]
1357 fn snapshot_query_with_pk_range() {
1358 let schema_name = "myschema".to_string();
1359 let table_name = "mytable".to_string();
1360 let table = MySqlTableName(schema_name.clone(), table_name.clone());
1361 let columns = ["id", "name"]
1362 .iter()
1363 .map(|col| MySqlColumnDesc {
1364 name: col.to_string(),
1365 column_type: None,
1366 meta: None,
1367 })
1368 .collect::<Vec<_>>();
1369 let desc = MySqlTableDesc {
1370 schema_name: schema_name.clone(),
1371 name: table_name.clone(),
1372 columns,
1373 keys: BTreeSet::default(),
1374 };
1375 let info = SourceOutputInfo {
1376 output_index: 1,
1377 table_name: table.clone(),
1378 desc,
1379 text_columns: vec![],
1380 exclude_columns: vec![],
1381 initial_gtid_set: Antichain::default(),
1382 resume_upper: Antichain::default(),
1383 export_id: mz_repr::GlobalId::User(1),
1384 binlog_full_metadata: false,
1385 };
1386
1387 let range = PkRange {
1389 pk_col: "`id`".to_string(),
1390 lower: Some("100".to_string()),
1391 upper: Some("200".to_string()),
1392 };
1393 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1394 assert_eq!(
1395 format!(
1396 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ? AND `id` < ?",
1397 schema_name, table_name
1398 ),
1399 query
1400 );
1401 assert_eq!(params, vec![Value::from("100"), Value::from("200")]);
1402
1403 let range = PkRange {
1405 pk_col: "`id`".to_string(),
1406 lower: None,
1407 upper: Some("200".to_string()),
1408 };
1409 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1410 assert_eq!(
1411 format!(
1412 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` < ?",
1413 schema_name, table_name
1414 ),
1415 query
1416 );
1417 assert_eq!(params, vec![Value::from("200")]);
1418
1419 let range = PkRange {
1421 pk_col: "`id`".to_string(),
1422 lower: Some("200".to_string()),
1423 upper: None,
1424 };
1425 let (query, params) = build_snapshot_query(std::slice::from_ref(&info), Some(&range));
1426 assert_eq!(
1427 format!(
1428 "SELECT `id`, `name` FROM `{}`.`{}` WHERE `id` >= ?",
1429 schema_name, table_name
1430 ),
1431 query
1432 );
1433 assert_eq!(params, vec![Value::from("200")]);
1434 }
1435
1436 #[mz_ore::test]
1437 fn test_worker_pk_range() {
1438 let splits = PkBoundaries {
1441 pk_col: "`id`".to_string(),
1442 boundaries: vec!["51".to_string()],
1443 };
1444 let r0 = worker_pk_range(&splits, 0, 0, 4).expect("worker 0");
1445 assert_eq!(r0.pk_col, "`id`");
1446 assert_eq!(r0.lower, None); assert_eq!(r0.upper.as_deref(), Some("51"));
1448 let r1 = worker_pk_range(&splits, 1, 0, 4).expect("worker 1");
1449 assert_eq!(r1.lower.as_deref(), Some("51"));
1450 assert_eq!(r1.upper, None); assert!(worker_pk_range(&splits, 2, 0, 4).is_none());
1453
1454 let splits = PkBoundaries {
1456 pk_col: "`id`".to_string(),
1457 boundaries: vec!["34".to_string(), "67".to_string()],
1458 };
1459 let r1 = worker_pk_range(&splits, 1, 0, 3).expect("worker 1");
1460 assert_eq!(r1.lower.as_deref(), Some("34"));
1461 assert_eq!(r1.upper.as_deref(), Some("67"));
1462
1463 let owner = 2;
1466 let owned = worker_pk_range(&splits, owner, owner, 3).expect("owner has work");
1467 assert_eq!(owned.lower, None);
1468 let mut ranges: Vec<_> = (0..3)
1469 .map(|w| {
1470 let r = worker_pk_range(&splits, w, owner, 3).expect("worker has work");
1471 (r.lower, r.upper)
1472 })
1473 .collect();
1474 ranges.sort();
1475 assert_eq!(
1476 ranges,
1477 vec![
1478 (None, Some("34".to_string())),
1479 (Some("34".to_string()), Some("67".to_string())),
1480 (Some("67".to_string()), None),
1481 ]
1482 );
1483 }
1484
1485 #[mz_ore::test]
1486 fn test_single_column_pk() {
1487 use mz_mysql_util::MySqlKeyDesc;
1488 use mz_repr::SqlColumnType;
1489
1490 let col = |name: &str, ty: SqlScalarType| MySqlColumnDesc {
1491 name: name.to_string(),
1492 column_type: Some(SqlColumnType {
1493 scalar_type: ty,
1494 nullable: false,
1495 }),
1496 meta: None,
1497 };
1498 let pk = |cols: &[&str]| {
1499 BTreeSet::from([MySqlKeyDesc {
1500 name: "PRIMARY".to_string(),
1501 is_primary: true,
1502 columns: cols.iter().map(|c| c.to_string()).collect(),
1503 }])
1504 };
1505 let desc = |columns, keys| MySqlTableDesc {
1506 schema_name: "s".to_string(),
1507 name: "t".to_string(),
1508 columns,
1509 keys,
1510 };
1511
1512 let (name, ty) = try_extract_single_column_pk(&desc(
1514 vec![col("id", SqlScalarType::Char { length: None })],
1515 pk(&["id"]),
1516 ))
1517 .expect("single-column pk");
1518 assert_eq!(name, "id");
1519 assert!(matches!(ty, SqlScalarType::Char { .. }));
1520
1521 let (name, ty) =
1522 try_extract_single_column_pk(&desc(vec![col("id", SqlScalarType::Bytes)], pk(&["id"])))
1523 .expect("single-column pk");
1524 assert_eq!(name, "id");
1525 assert!(matches!(ty, SqlScalarType::Bytes));
1526
1527 assert!(
1529 try_extract_single_column_pk(&desc(
1530 vec![
1531 col("a", SqlScalarType::Char { length: None }),
1532 col("b", SqlScalarType::Int64),
1533 ],
1534 pk(&["a", "b"]),
1535 ))
1536 .is_none()
1537 );
1538
1539 assert!(
1541 try_extract_single_column_pk(&desc(
1542 vec![col("id", SqlScalarType::Int64)],
1543 BTreeSet::default()
1544 ))
1545 .is_none()
1546 );
1547 }
1548}