Skip to main content

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}