mz_storage/source/mysql/replication.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Renders the replication side of the [`MySqlSourceConnection`] ingestion dataflow.
11//!
12//! # Progress tracking using Partitioned Timestamps
13//!
14//! This dataflow uses a Partitioned Timestamp implementation to represent the GTID Set that
15//! comprises the full set of committed transactions from the MySQL Server. The frontier
16//! representing progress for this dataflow represents the full range of possible UUIDs +
17//! Transaction IDs of future GTIDs that could be added to the GTID Set.
18//!
19//! See the [`mz_storage_types::sources::mysql::GtidPartition`] type for more information.
20//!
21//! To maintain a complete frontier of the full UUID GTID range, we use a
22//! [`partitions::GtidReplicationPartitions`] struct to store the GTID Set as a set of partitions.
23//! This allows us to easily advance the frontier each time we see a new GTID on the replication
24//! stream.
25//!
26//! # Resumption
27//!
28//! When the dataflow is resumed, the MySQL replication stream is started from the GTID frontier
29//! of the minimum frontier across all source outputs. This is compared against the GTID set that
30//! may still be obtained from the MySQL server, using the @@GTID_PURGED value in MySQL to
31//! determine GTIDs that are no longer available in the binlog and to put the source in an error
32//! state if we cannot resume from the GTID frontier.
33//!
34//! # Rewinds
35//!
36//! The replication stream may be resumed from a point before the snapshot for a specific output
37//! occurs. To avoid double-counting updates that were present in the snapshot, we store a map
38//! of pending rewinds that we've received from the snapshot operator, and when we see updates
39//! for an output that were present in the snapshot, we negate the snapshot update
40//! (at the minimum timestamp) and send it again at the correct GTID.
41
42use std::collections::BTreeMap;
43use std::num::NonZeroU64;
44use std::pin::pin;
45use std::sync::Arc;
46
47use differential_dataflow::AsCollection;
48use futures::{FutureExt, StreamExt};
49use itertools::Itertools;
50use mysql_async::prelude::Queryable;
51use mysql_async::{BinlogStream, BinlogStreamRequest, GnoInterval, Sid};
52use mz_ore::future::InTask;
53use mz_ssh_util::tunnel_manager::ManagedSshTunnelHandle;
54use timely::PartialOrder;
55use timely::container::CapacityContainerBuilder;
56use timely::dataflow::channels::pact::Exchange;
57use timely::dataflow::operators::Concat;
58use timely::dataflow::operators::core::Map;
59use timely::dataflow::{Scope, StreamVec};
60use timely::progress::{Antichain, Timestamp};
61use tracing::trace;
62use uuid::Uuid;
63
64use mz_mysql_util::{
65 ER_SOURCE_FATAL_ERROR_READING_BINLOG_CODE, MySqlConn, MySqlError, query_sys_var,
66};
67use mz_ore::cast::CastFrom;
68use mz_repr::GlobalId;
69use mz_storage_types::dyncfgs::STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD;
70use mz_storage_types::errors::DataflowError;
71use mz_storage_types::sources::MySqlSourceConnection;
72use mz_storage_types::sources::mysql::{GtidPartition, GtidState, gtid_set_frontier};
73use mz_timely_util::builder_async::{
74 Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
75};
76
77use crate::metrics::source::mysql::MySqlSourceMetrics;
78use crate::source::RawSourceCreationConfig;
79use crate::source::types::{SharedFuel, SignaledFuture, SourceMessage, StackedCollection};
80
81use super::{
82 DefiniteError, ReplicationError, RewindRequest, SourceOutputInfo, TransientError,
83 return_definite_error, validate_mysql_repl_settings,
84};
85
86mod context;
87mod events;
88mod partitions;
89
90/// Used as a partition id to determine if the worker is
91/// responsible for reading from the MySQL replication stream
92static REPL_READER: &str = "reader";
93
94/// A constant arbitrary offset to add to the source-id to
95/// produce a deterministic server-id for identifying Materialize
96/// as a replica on the upstream MySQL server.
97/// TODO(roshan): Add user-facing documentation for this
98static REPLICATION_SERVER_ID_OFFSET: u32 = 524000;
99
100/// Renders the replication dataflow. See the module documentation for more
101/// information.
102pub(crate) fn render<'scope>(
103 scope: Scope<'scope, GtidPartition>,
104 config: RawSourceCreationConfig,
105 connection: MySqlSourceConnection,
106 source_outputs: Vec<SourceOutputInfo>,
107 rewind_stream: StreamVec<'scope, GtidPartition, RewindRequest>,
108 metrics: MySqlSourceMetrics,
109) -> (
110 Vec<StackedCollection<'scope, GtidPartition, Result<SourceMessage, DataflowError>>>,
111 StreamVec<'scope, GtidPartition, ReplicationError>,
112 PressOnDropButton,
113) {
114 let op_name = format!("MySqlReplicationReader({})", config.id);
115 let mut builder = AsyncOperatorBuilder::new(op_name, scope);
116
117 // Read at render time, matching the value the persist sink reads in the same dataflow build.
118 // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's
119 // creation and disagree with the sink because the `ConfigSet` is updated in place.
120 // A nonzero lookahead is the switch, see `STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD`.
121 let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD
122 .get(config.config.config_set())
123 .is_zero();
124
125 let repl_reader_id = u64::cast_from(config.responsible_worker(REPL_READER));
126 // One data output port per source export, in output index order. See
127 // `ReplContext::downgrade_data_cap_sets` for when each port advances.
128 let export_count = config.source_exports.len();
129 let mut data_outputs = Vec::with_capacity(export_count);
130 let mut data_streams = Vec::with_capacity(export_count);
131 for _ in 0..export_count {
132 let (output, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
133 data_outputs.push(output);
134 data_streams.push(stream);
135 }
136 // Captures DefiniteErrors that affect the entire source, including all outputs
137 let (definite_error_handle, definite_errors) =
138 builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
139 // The operator collects every rewind request before it downgrades any data port. The rewind
140 // input therefore needs no connection to the data ports.
141 let mut rewind_input =
142 builder.new_disconnected_input(rewind_stream, Exchange::new(move |_| repl_reader_id));
143
144 let output_indexes = source_outputs
145 .iter()
146 .map(|output| output.output_index)
147 .collect_vec();
148
149 metrics.tables.set(u64::cast_from(source_outputs.len()));
150
151 let (button, transient_errors) = builder.build_fallible(move |caps| {
152 let busy_signal = Arc::clone(&config.busy_signal);
153 Box::pin(SignaledFuture::new(busy_signal, async move {
154 let (id, worker_id) = (config.id, config.worker_id);
155 let (data_cap_sets, caps) = caps.split_at_mut(export_count);
156 let [definite_error_cap_set]: &mut [_; 1] = caps.try_into().unwrap();
157 let mut data_outputs = SharedFuel::new(data_outputs);
158
159 // Only run the replication reader on the worker responsible for it.
160 if !config.responsible_for(REPL_READER) {
161 return Ok(());
162 }
163
164 let connection_config = connection
165 .connection
166 .config(
167 &config.config.connection_context.secrets_reader,
168 &config.config,
169 InTask::Yes,
170 )
171 .await?;
172
173 let mut conn = connection_config
174 .connect(
175 &format!("timely-{worker_id} MySQL replication reader"),
176 &config.config.connection_context.ssh_tunnel_manager,
177 )
178 .await?;
179
180 // Get the set of GTIDs that have been purged from the binlogs. The assumption is that this
181 // represents the frontier of possible GTIDs that exist in the binlog, that we can start
182 // replicating from.
183 let binlog_purged_set = query_sys_var(&mut conn, "global.gtid_purged").await?;
184 let binlog_frontier = match gtid_set_frontier(&binlog_purged_set) {
185 Ok(frontier) => frontier,
186 Err(err) => {
187 let err = DefiniteError::UnsupportedGtidState(err.to_string());
188 tracing::warn!(%id, "Unable to determine GTID frontier from @@gtid_purged: {err}");
189 return Ok(
190 // If GTID intervals in the binlog are not available in a monotonic consecutive
191 // order this breaks all of our assumptions and there is nothing else we can do.
192 // This can occur if the mysql server is restored to a previous point-in-time
193 // or if a user manually adds transactions to the @@gtid_purged system var.
194 return_definite_error(
195 err,
196 &output_indexes,
197 &mut data_outputs,
198 data_cap_sets,
199 &definite_error_handle,
200 definite_error_cap_set,
201 )
202 .await,
203 );
204 }
205 };
206
207 trace!(%id, "timely-{worker_id} replication binlog frontier: {binlog_frontier:?}");
208
209 // upstream-table-name: Vec<SourceOutputInfo> since multiple
210 // outputs can refer to the same table
211 let mut table_info = BTreeMap::new();
212 let mut output_uppers = Vec::new();
213
214 // Calculate the lowest frontier across all outputs, which represents the point which
215 // we should start replication from.
216 let min_frontier = Antichain::from_elem(GtidPartition::minimum());
217 for output in source_outputs.into_iter() {
218 // If an output is resuming at the minimum frontier then its snapshot
219 // has not yet been committed.
220 // We need to resume from a frontier before the output's snapshot frontier
221 // to ensure we don't miss updates that happen after the snapshot was taken.
222 //
223 // This also ensures that tables created as part of the same CREATE SOURCE
224 // statement are 'effectively' snapshot at the same GTID Set, even if their
225 // actual snapshot frontiers are different due to a restart.
226 //
227 // We've chosen the frontier beyond the GTID Set recorded
228 // during purification as this resume point.
229 if &output.resume_upper == &min_frontier {
230 output_uppers.push(output.initial_gtid_set.clone());
231 } else {
232 output_uppers.push(output.resume_upper.clone());
233 }
234
235 table_info
236 .entry(output.table_name.clone())
237 .or_insert_with(Vec::new)
238 .push(output);
239 }
240 let resume_upper = match output_uppers.len() {
241 0 => {
242 // If there are no outputs to replicate then we will just be updating the
243 // source progress collection. In this case we can just start from the head of
244 // the binlog to avoid wasting time on old events.
245 trace!(%id, "timely-{worker_id} replication reader found no outputs \
246 to replicate, using latest gtid_executed as resume_upper");
247 let executed_gtid_set =
248 query_sys_var(&mut conn, "global.gtid_executed").await?;
249
250 gtid_set_frontier(&executed_gtid_set)?
251 }
252 _ => Antichain::from_iter(output_uppers.into_iter().flatten()),
253 };
254
255 // Validate that we can actually resume from this upper.
256 if !PartialOrder::less_equal(&binlog_frontier, &resume_upper) {
257 let err = DefiniteError::BinlogMissingResumePoint(
258 format!("{:?}", binlog_frontier),
259 format!("{:?}", resume_upper),
260 );
261 return Ok(return_definite_error(
262 err,
263 &output_indexes,
264 &mut data_outputs,
265 data_cap_sets,
266 &definite_error_handle,
267 definite_error_cap_set,
268 )
269 .await);
270 };
271
272 trace!(%id, "timely-{worker_id} replication reader started at {:?}", resume_upper);
273
274 let mut rewinds = BTreeMap::new();
275 while let Some(event) = rewind_input.next().await {
276 if let AsyncEvent::Data(_, data) = event {
277 for req in data {
278 // Check that the replication stream will be resumed from the snapshot point or before.
279 if !PartialOrder::less_equal(&resume_upper, &req.snapshot_upper) {
280 let err = DefiniteError::BinlogMissingResumePoint(
281 format!("{:?}", resume_upper),
282 format!("{:?}", req.snapshot_upper),
283 );
284 return Ok(return_definite_error(
285 err,
286 &output_indexes,
287 &mut data_outputs,
288 data_cap_sets,
289 &definite_error_handle,
290 definite_error_cap_set,
291 )
292 .await);
293 };
294 // If the snapshot point is the same as the resume point then we don't need to rewind
295 if resume_upper != req.snapshot_upper {
296 rewinds.insert(req.output_index, req);
297 }
298 }
299 }
300 }
301 trace!(%id, "timely-{worker_id} pending rewinds {rewinds:?}");
302
303 // We don't use _conn_tunnel_handle here, but need to keep it around to ensure that the
304 // SSH tunnel is not dropped until the replication stream is dropped.
305 let (binlog_stream, _conn_tunnel_handle) =
306 match raw_stream(&config, conn, &resume_upper).await? {
307 Ok(stream) => stream,
308 // If the replication stream cannot be obtained in a definite way there is
309 // nothing else to do. These errors are not retractable.
310 Err(err) => {
311 return Ok(return_definite_error(
312 err,
313 &output_indexes,
314 &mut data_outputs,
315 data_cap_sets,
316 &definite_error_handle,
317 definite_error_cap_set,
318 )
319 .await);
320 }
321 };
322 let mut stream = pin!(binlog_stream.peekable());
323
324 // Store all partitions from the resume_upper so we can create a frontier that comprises
325 // timestamps for partitions representing the full range of UUIDs to advance our main
326 // capabilities.
327 let mut data_partitions =
328 partitions::GtidReplicationPartitions::from(resume_upper.clone());
329 let mut progress_partitions =
330 partitions::GtidReplicationPartitions::from(resume_upper.clone());
331
332 let mut repl_context = context::ReplContext::new(
333 &config,
334 &connection_config,
335 stream.as_mut(),
336 &table_info,
337 &metrics,
338 &mut data_outputs,
339 data_cap_sets,
340 rewinds,
341 concurrent_replication,
342 );
343 repl_context.advance_data_upper("resume", resume_upper);
344 repl_context.downgrade_data_cap_sets();
345
346 let mut active_tx: Option<(Uuid, NonZeroU64)> = None;
347
348 let mut row_event_buffer = Vec::new();
349
350 while let Some(event) = repl_context.stream.next().await {
351 use mysql_async::binlog::events::*;
352 let event = event?;
353 let event_data = event.read_data()?;
354 metrics.total.inc();
355
356 match event_data {
357 Some(EventData::XidEvent(_)) => {
358 // We've received a transaction commit event, which means that we've seen
359 // all events for the current GTID and we can advance the frontier beyond.
360 let (source_id, tx_id) = active_tx.take().expect("unexpected xid event");
361
362 // Increment the transaction-id to the next GTID we should see from this source-id
363 let next_tx_id = tx_id.checked_add(1).unwrap();
364 let next_gtid =
365 GtidPartition::new_singleton(source_id, GtidState::Active(next_tx_id));
366
367 if let Err(err) = data_partitions.advance_frontier(next_gtid) {
368 return Ok(return_definite_error(
369 err,
370 &output_indexes,
371 &mut data_outputs,
372 data_cap_sets,
373 &definite_error_handle,
374 definite_error_cap_set,
375 )
376 .await);
377 }
378 let new_upper = data_partitions.frontier();
379 repl_context.advance_data_upper("xid_event", new_upper);
380 }
381 // We receive a GtidEvent that tells us the GTID of the incoming RowsEvents (and other events)
382 Some(EventData::GtidEvent(event)) => {
383 let source_id = Uuid::from_bytes(event.sid());
384 let tx_id = NonZeroU64::new(event.gno()).unwrap();
385
386 // We are potentially about to ingest a big transaction that we don't want
387 // to store in memory. For this reason we are immediately downgrading our
388 // progress frontier to one that includes the upcoming transaction. This
389 // will cause a remap binding to be minted right away and so the data of
390 // the transaction will not accumulate in the reclock operator.
391 let next_tx_id = tx_id.checked_add(1).unwrap();
392 let next_gtid =
393 GtidPartition::new_singleton(source_id, GtidState::Active(next_tx_id));
394
395 if let Err(err) = progress_partitions.advance_frontier(next_gtid) {
396 return Ok(return_definite_error(
397 err,
398 &output_indexes,
399 &mut data_outputs,
400 data_cap_sets,
401 &definite_error_handle,
402 definite_error_cap_set,
403 )
404 .await);
405 }
406 // Store the information of the active transaction for the subsequent events
407 active_tx = Some((source_id, tx_id));
408 }
409 Some(EventData::RowsEvent(data)) => {
410 let (source_id, tx_id) = active_tx
411 .clone()
412 .expect("gtid cap should be set by previous GtidEvent");
413 let cur_gtid =
414 GtidPartition::new_singleton(source_id, GtidState::Active(tx_id));
415
416 events::handle_rows_event(
417 data,
418 &mut repl_context,
419 &cur_gtid,
420 &mut row_event_buffer,
421 )
422 .await?;
423
424 // Advance the frontier up to the point right before this GTID, since we
425 // might still see other events that are part of this same GTID, such as
426 // row events for multiple tables or large row events split into multiple.
427 if let Err(err) = data_partitions.advance_frontier(cur_gtid) {
428 return Ok(return_definite_error(
429 err,
430 &output_indexes,
431 &mut data_outputs,
432 data_cap_sets,
433 &definite_error_handle,
434 definite_error_cap_set,
435 )
436 .await);
437 }
438 let new_upper = data_partitions.frontier();
439 repl_context.advance_data_upper("rows_event", new_upper);
440 }
441 Some(EventData::QueryEvent(event)) => {
442 let (source_id, tx_id) = active_tx
443 .clone()
444 .expect("gtid cap should be set by previous GtidEvent");
445 let cur_gtid =
446 GtidPartition::new_singleton(source_id, GtidState::Active(tx_id));
447
448 let should_advance =
449 events::handle_query_event(event, &mut repl_context, &cur_gtid).await?;
450
451 if should_advance {
452 active_tx = None;
453 // Increment the transaction-id to the next GTID we should see from this source-id
454 let next_tx_id = tx_id.checked_add(1).unwrap();
455 let next_gtid = GtidPartition::new_singleton(
456 source_id,
457 GtidState::Active(next_tx_id),
458 );
459
460 if let Err(err) = data_partitions.advance_frontier(next_gtid) {
461 return Ok(return_definite_error(
462 err,
463 &output_indexes,
464 &mut data_outputs,
465 data_cap_sets,
466 &definite_error_handle,
467 definite_error_cap_set,
468 )
469 .await);
470 }
471 let new_upper = data_partitions.frontier();
472 repl_context.advance_data_upper("query_event", new_upper);
473 }
474 }
475 _ => {
476 // TODO: Handle other event types
477 metrics.ignored.inc();
478 }
479 }
480
481 // Timely observes capability changes only when the operator yields, so the sets
482 // are downgraded only before the stream blocks or after a fuel yield. A downgrade
483 // after a fuel yield takes effect at the next yield.
484 let fuel_yielded = repl_context.data_outputs.take_yielded();
485 if fuel_yielded || repl_context.stream.as_mut().peek().now_or_never().is_none() {
486 repl_context.downgrade_data_cap_sets();
487 }
488 }
489 // We never expect the replication stream to gracefully end
490 Err(TransientError::ReplicationEOF)
491 }))
492 });
493
494 // TODO: Split row decoding into a separate operator that can be distributed across all workers
495
496 let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
497
498 let data_collections = data_streams
499 .into_iter()
500 .map(|stream| stream.as_collection())
501 .collect();
502
503 (data_collections, errors, button.press_on_drop())
504}
505
506/// Produces the replication stream from the MySQL server. This will return all transactions
507/// whose GTIDs were not present in the GTID UUIDs referenced in the `resume_uppper` partitions.
508async fn raw_stream(
509 config: &RawSourceCreationConfig,
510 mut conn: MySqlConn,
511 resume_upper: &Antichain<GtidPartition>,
512) -> Result<Result<(BinlogStream, Option<ManagedSshTunnelHandle>), DefiniteError>, TransientError> {
513 // Verify the MySQL system settings are correct for consistent row-based replication using GTIDs
514 match validate_mysql_repl_settings(&mut conn).await {
515 Err(err @ MySqlError::InvalidSystemSetting { .. }) => {
516 return Ok(Err(DefiniteError::ServerConfigurationError(
517 err.to_string(),
518 )));
519 }
520 Err(err) => Err(err)?,
521 Ok(()) => (),
522 };
523
524 // To start the stream we need to provide a GTID set of the transactions that we've 'seen'
525 // and the server will send us all transactions that have been committed after that point.
526 // NOTE: The 'Gno' intervals in this transaction-set use an open set [start, end)
527 // interval, which is different than the closed-set [start, end] form returned by the
528 // @gtid_executed system variable. So the intervals we construct in this GTID set
529 // end with the value of the transaction-id that we want to start replication at,
530 // which happens to be the same as the definition of a frontier value.
531 // https://dev.mysql.com/doc/refman/8.0/en/replication-options-gtids.html#sysvar_gtid_executed
532 // https://dev.mysql.com/doc/dev/mysql-server/latest/classGtid__set.html#ab46da5ceeae0198b90f209b0a8be2a24
533 let seen_gtids = resume_upper
534 .iter()
535 .flat_map(|partition| match partition.timestamp() {
536 GtidState::Absent => None,
537 GtidState::Active(frontier_time) => {
538 let part_uuid = partition
539 .interval()
540 .singleton()
541 .expect("Non-absent paritions will be singletons");
542 // NOTE: Since we enforce replica_preserve_commit_order=ON we can start the interval at 1
543 // since we know that all transactions with a lower transaction id were monotonic
544 Some(
545 Sid::new(*part_uuid.as_bytes())
546 .with_interval(GnoInterval::new(1, frontier_time.get())),
547 )
548 }
549 })
550 .collect::<Vec<_>>();
551
552 // Request that the stream provide us with a heartbeat message when no other messages have
553 // been sent. This isn't strictly necessary, but is a lightweight additional general
554 // health-check for the replication stream
555 //
556 // Interpolating an integer nanosecond value; not parameterizable in MySQL `SET`.
557 #[allow(clippy::disallowed_methods)]
558 conn.query_drop(format!(
559 "SET @master_heartbeat_period = {};",
560 mz_storage_types::dyncfgs::MYSQL_REPLICATION_HEARTBEAT_INTERVAL
561 .get(config.config.config_set())
562 .as_nanos()
563 ))
564 .await?;
565
566 // Generate a deterministic server-id for identifying us as a replica on the upstream mysql server.
567 // The value does not actually matter since it's irrelevant for GTID-based replication and won't
568 // cause errors if it happens to be the same as another replica in the mysql cluster (based on testing),
569 // but by setting it to a constant value we can make it easier for users to identify Materialize connections
570 let server_id = match config.id {
571 GlobalId::System(id) => id,
572 GlobalId::User(id) => id,
573 GlobalId::Transient(id) => id,
574 _ => unreachable!(),
575 };
576 let server_id = match u32::try_from(server_id) {
577 Ok(id) if id + REPLICATION_SERVER_ID_OFFSET < u32::MAX => id + REPLICATION_SERVER_ID_OFFSET,
578 _ => REPLICATION_SERVER_ID_OFFSET,
579 };
580
581 trace!(
582 "requesting replication stream with seen_gtids: {seen_gtids:?} \
583 and server_id: {server_id:?}"
584 );
585
586 // We need to transform the connection into a BinlogStream (which takes the `Conn` by value),
587 // but to avoid dropping any active SSH tunnel used by the connection we need to preserve the
588 // tunnel handle and return it
589 let (inner_conn, conn_tunnel_handle) = conn.take();
590
591 let repl_stream = match inner_conn
592 .get_binlog_stream(
593 BinlogStreamRequest::new(server_id)
594 .with_gtid()
595 .with_gtid_set(seen_gtids),
596 )
597 .await
598 {
599 Ok(stream) => stream,
600 Err(mysql_async::Error::Server(ref server_err))
601 if server_err.code == ER_SOURCE_FATAL_ERROR_READING_BINLOG_CODE =>
602 {
603 // The GTID set we requested is no longer available
604 return Ok(Err(DefiniteError::BinlogNotAvailable));
605 }
606 // TODO: handle other error types. Some may require a re-snapshot and some may be transient
607 Err(err) => return Err(err.into()),
608 };
609
610 Ok(Ok((repl_stream, conn_tunnel_handle)))
611}