Skip to main content

mz_storage/source/postgres/
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 logical replication side of the [`PostgresSourceConnection`] ingestion dataflow.
11//!
12//! ```text
13//!              o
14//!              │rewind
15//!              │requests
16//!          ╭───┴────╮
17//!          │exchange│ (collect all requests to one worker)
18//!          ╰───┬────╯
19//!           ┏━━v━━━━━━━━━━┓
20//!           ┃ replication ┃ (single worker)
21//!           ┃   reader    ┃
22//!           ┗━┯━━━━━━━━┯━━┛
23//!             │raw     │
24//!             │data    │
25//!        ╭────┴─────╮  │
26//!        │distribute│  │ (distribute to all workers)
27//!        ╰────┬─────╯  │
28//! ┏━━━━━━━━━━━┷━┓      │
29//! ┃ replication ┃      │ (parallel decode)
30//! ┃   decoder   ┃      │
31//! ┗━━━━━┯━━━━━━━┛      │
32//!       │ replication  │ progress
33//!       │ updates      │ output
34//!       v              v
35//! ```
36//!
37//! # Progress tracking
38//!
39//! In order to avoid causing excessive resource usage in the upstream server it's important to
40//! track the LSN that we have successfully committed to persist and communicate that back to
41//! PostgreSQL. Under normal operation this gauge of progress is provided by the presence of
42//! transactions themselves. Since at a given LSN offset there can be only a single message, when a
43//! transaction is received and processed we can infer that we have seen all the messages that are
44//! not beyond `commit_lsn + 1`.
45//!
46//! Things are a bit more complicated in the absence of transactions though because even though we
47//! don't receive any the server might very well be generating WAL records. This can happen if
48//! there is a separate logical database performing writes (which is the case for RDS databases),
49//! or, in servers running PostgreSQL version 15 or greater, the logical replication process
50//! includes an optimization that omits empty transactions, which can happen if you're only
51//! replicating a subset of the tables and there writes going to the other ones.
52//!
53//! If we fail to detect this situation and don't send LSN feedback in a timely manner the server
54//! will be forced to keep around WAL data that can eventually lead to disk space exhaustion.
55//!
56//! In the absence of transactions the only available piece of information in the replication
57//! stream are keepalive messages. Keepalive messages are documented[1] to contain the current end
58//! of WAL on the server. That is a useless number when it comes to progress tracking because there
59//! might be pending messages at LSNs between the last received commit_lsn and the current end of
60//! WAL.
61//!
62//! Fortunately for us, the documentation for PrimaryKeepalive messages is wrong and it actually
63//! contains the last *sent* LSN[2]. Here sent doesn't necessarily mean sent over the wire, but
64//! sent to the upstream process that is handling producing the logical stream. Therefore, if we
65//! receive a keepalive with a particular LSN we can be certain that there are no other replication
66//! messages at previous LSNs, because they would have been already generated and received. We
67//! therefore connect the keepalive messages directly to our capability.
68//!
69//! [1]: https://www.postgresql.org/docs/15/protocol-replication.html#PROTOCOL-REPLICATION-START-REPLICATION
70//! [2]: https://www.postgresql.org/message-id/CAFPTHDZS9O9WG02EfayBd6oONzK%2BqfUxS6AbVLJ7W%2BKECza2gg%40mail.gmail.com
71
72use std::collections::BTreeMap;
73use std::convert::Infallible;
74use std::pin::pin;
75use std::rc::Rc;
76use std::str::FromStr;
77use std::sync::Arc;
78use std::sync::LazyLock;
79use std::time::Instant;
80use std::time::{Duration, SystemTime, UNIX_EPOCH};
81
82use differential_dataflow::AsCollection;
83use futures::{FutureExt, Stream as AsyncStream, StreamExt, TryStreamExt};
84use mz_dyncfg::ConfigSet;
85use mz_ore::cast::CastFrom;
86use mz_ore::future::InTask;
87use mz_postgres_util::PostgresError;
88use mz_postgres_util::{Client, Sql, execute, query_opt, simple_query_opt, sql};
89use mz_repr::{Datum, DatumVec, Diff, Row};
90use mz_storage_types::dyncfgs::PG_SCHEMA_VALIDATION_INTERVAL;
91use mz_storage_types::dyncfgs::PG_SOURCE_VALIDATE_TIMELINE;
92use mz_storage_types::errors::DataflowError;
93use mz_storage_types::sources::{MzOffset, PostgresSourceConnection};
94use mz_timely_util::builder_async::{
95    AsyncOutputHandle, Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder,
96    PressOnDropButton,
97};
98use postgres_replication::LogicalReplicationStream;
99use postgres_replication::protocol::{LogicalReplicationMessage, ReplicationMessage, TupleData};
100use serde::{Deserialize, Serialize};
101use timely::container::CapacityContainerBuilder;
102use timely::dataflow::channels::pact::{Exchange, Pipeline};
103use timely::dataflow::operators::Capability;
104use timely::dataflow::operators::Concat;
105use timely::dataflow::operators::Operator;
106use timely::dataflow::operators::core::Map;
107use timely::dataflow::{Scope, StreamVec};
108use timely::progress::Antichain;
109use tokio::sync::{mpsc, watch};
110use tokio_postgres::error::SqlState;
111use tokio_postgres::types::PgLsn;
112use tracing::{error, trace};
113
114use crate::metrics::source::postgres::PgSourceMetrics;
115use crate::source::RawSourceCreationConfig;
116use crate::source::postgres::verify_schema;
117use crate::source::postgres::{DefiniteError, ReplicationError, SourceOutputInfo, TransientError};
118use crate::source::probe;
119use crate::source::types::{FuelSize, Probe, SignaledFuture, SourceMessage, StackedCollection};
120
121/// A logical replication message from the server.
122type LogicalReplMsg = ReplicationMessage<LogicalReplicationMessage>;
123
124/// A decoded row from a transaction with source information.
125type DecodedRow = (u32, usize, Result<Row, DefiniteError>, Diff);
126
127/// Postgres epoch is 2000-01-01T00:00:00Z
128static PG_EPOCH: LazyLock<SystemTime> =
129    LazyLock::new(|| UNIX_EPOCH + Duration::from_secs(946_684_800));
130
131// A request to rewind a snapshot taken at `snapshot_lsn` to the initial LSN of the replication
132// slot. This is accomplished by emitting `(data, 0, -diff)` for all updates `(data, lsn, diff)`
133// whose `lsn <= snapshot_lsn` and that the output does not ignore (see
134// `SourceOutputInfo::ignores`). By convention the snapshot is always emitted at LSN 0.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub(crate) struct RewindRequest {
137    /// The output index that should be rewound.
138    pub(crate) output_index: usize,
139    /// The LSN that the snapshot was taken at.
140    pub(crate) snapshot_lsn: MzOffset,
141}
142
143/// Renders the replication dataflow. See the module documentation for more information.
144pub(crate) fn render<'scope>(
145    scope: Scope<'scope, MzOffset>,
146    config: RawSourceCreationConfig,
147    connection: PostgresSourceConnection,
148    table_info: BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
149    rewind_stream: StreamVec<'scope, MzOffset, RewindRequest>,
150    slot_ready_stream: StreamVec<'scope, MzOffset, Infallible>,
151    committed_uppers: impl futures::Stream<Item = Antichain<MzOffset>> + 'static,
152    metrics: PgSourceMetrics,
153) -> (
154    StackedCollection<'scope, MzOffset, (usize, Result<SourceMessage, DataflowError>)>,
155    StreamVec<'scope, MzOffset, Probe<MzOffset>>,
156    StreamVec<'scope, MzOffset, ReplicationError>,
157    PressOnDropButton,
158) {
159    let op_name = format!("ReplicationReader({})", config.id);
160    let mut builder = AsyncOperatorBuilder::new(op_name, scope.clone());
161
162    let slot_reader = u64::cast_from(config.responsible_worker("slot"));
163    let (data_output, data_stream) = builder.new_output();
164    let (definite_error_handle, definite_errors) =
165        builder.new_output::<CapacityContainerBuilder<_>>();
166    let (probe_output, probe_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
167
168    let mut rewind_input =
169        builder.new_disconnected_input(rewind_stream, Exchange::new(move |_| slot_reader));
170    let mut slot_ready_input = builder.new_disconnected_input(slot_ready_stream, Pipeline);
171    let output_uppers = table_info
172        .iter()
173        .flat_map(|(_, outputs)| outputs.values().map(|o| o.resume_upper.clone()))
174        .collect::<Vec<_>>();
175    metrics.tables.set(u64::cast_from(output_uppers.len()));
176
177    let reader_table_info = table_info.clone();
178    let (button, transient_errors) = builder.build_fallible(move |caps| {
179        let mut table_info = reader_table_info;
180        let busy_signal = Arc::clone(&config.busy_signal);
181        Box::pin(SignaledFuture::new(busy_signal, async move {
182            let (id, worker_id) = (config.id, config.worker_id);
183            let [data_cap_set, definite_error_cap_set, probe_cap]: &mut [_; 3] =
184                caps.try_into().unwrap();
185
186            if !config.responsible_for("slot") {
187                // Emit 0, to mark this worker as having started up correctly.
188                for stat in config.statistics.values() {
189                    stat.set_offset_known(0);
190                    stat.set_offset_committed(0);
191                }
192                return Ok(());
193            }
194
195            // Determine the slot lsn.
196            let connection_config = connection
197                .connection
198                .config(
199                    &config.config.connection_context.secrets_reader,
200                    &config.config,
201                    InTask::Yes,
202                )
203                .await?;
204
205            let slot = &connection.publication_details.slot;
206            let replication_client = connection_config
207                .connect_replication(&config.config.connection_context.ssh_tunnel_manager)
208                .await?;
209
210            let metadata_client = connection_config
211                .connect(
212                    "replication metadata",
213                    &config.config.connection_context.ssh_tunnel_manager,
214                )
215                .await?;
216            let metadata_client = Arc::new(metadata_client);
217
218            while let Some(_) = slot_ready_input.next().await {
219                // Wait for the slot to be created
220            }
221
222            // The slot is always created by the snapshot operator. If the slot doesn't exist,
223            // when this check runs, this operator will return an error.
224            let slot_metadata = super::fetch_slot_metadata(
225                &*metadata_client,
226                slot,
227                mz_storage_types::dyncfgs::PG_FETCH_SLOT_RESUME_LSN_INTERVAL
228                    .get(config.config.config_set()),
229            )
230            .await?;
231
232            // We're the only application that should be using this replication
233            // slot. The only way that there can be another connection using
234            // this slot under normal operation is if there's a stale TCP
235            // connection from a prior incarnation of the source holding on to
236            // the slot. We don't want to wait for the WAL sender timeout and/or
237            // TCP keepalives to time out that connection, because these values
238            // are generally under the control of the DBA and may not time out
239            // the connection for multiple minutes, or at all. Instead we just
240            // force kill the connection that's using the slot.
241            //
242            // Note that there's a small risk that *we're* the zombie cluster
243            // that should not be using the replication slot. Kubernetes cannot
244            // 100% guarantee that only one cluster is alive at a time. However,
245            // this situation should not last long, and the worst that can
246            // happen is a bit of transient thrashing over ownership of the
247            // replication slot.
248            if let Some(active_pid) = slot_metadata.active_pid {
249                tracing::warn!(
250                    %id, %active_pid,
251                    "replication slot already in use; will attempt to kill existing connection",
252                );
253
254                match execute(
255                    &**metadata_client,
256                    sql!("SELECT pg_terminate_backend($1)"),
257                    &[&active_pid],
258                )
259                .await
260                {
261                    Ok(_) => {
262                        tracing::info!(
263                            "successfully killed existing connection; \
264                            starting replication is likely to succeed"
265                        );
266                        // Note that `pg_terminate_backend` does not wait for
267                        // the termination of the targeted connection to
268                        // complete. We may try to start replication before the
269                        // targeted connection has cleaned up its state. That's
270                        // okay. If that happens we'll just try again from the
271                        // top via the suspend-and-restart flow.
272                    }
273                    Err(e) => {
274                        tracing::warn!(
275                            %e,
276                            "failed to kill existing replication connection; \
277                            replication will likely fail to start"
278                        );
279                        // Continue on anyway, just in case the replication slot
280                        // is actually available. Maybe PostgreSQL has some
281                        // staleness when it reports `active_pid`, for example.
282                    }
283                }
284            }
285
286            // The overall resumption point for this source is the minimum of the resumption points
287            // contributed by each of the outputs.
288            let resume_lsn = output_uppers
289                .iter()
290                .flat_map(|f| f.elements())
291                .map(|&lsn| {
292                    // An output is either an output that has never had data committed to it or one
293                    // that has and needs to resume. We differentiate between the two by checking
294                    // whether an output wishes to "resume" from the minimum timestamp. In that case
295                    // its contribution to the overal resumption point is the earliest point available
296                    // in the slot. This information would normally be something that the storage
297                    // controller figures out in the form of an as-of frontier, but at the moment the
298                    // storage controller does not have visibility into what the replication slot is
299                    // doing.
300                    if lsn == MzOffset::from(0) {
301                        slot_metadata.confirmed_flush_lsn
302                    } else {
303                        lsn
304                    }
305                })
306                .min();
307            let Some(resume_lsn) = resume_lsn else {
308                std::future::pending::<()>().await;
309                return Ok(());
310            };
311            // If we don't set "offset_committed" now, it'll be stuck at 0 (the default value)
312            // until we finish processing the table snapshot. If the snapshot is large, that could be a long time.
313            // This confuses the ingestion lag calculation in the UI, causing it to yield erroneously high values.
314            for stat in config.statistics.values() {
315                stat.set_offset_committed(resume_lsn.offset);
316            }
317            tracing::info!(
318                %id,
319                "timely-{worker_id} replication reader started lsn={resume_lsn}, \
320                    waiting for snapshot to complete"
321            );
322
323            // Emitting an initial probe before we start waiting for rewinds ensures that we will
324            // have a timestamp binding in the remap collection while the snapshot is processed.
325            // This is important because otherwise the snapshot updates would need to be buffered
326            // in the reclock operator, instead of being spilled to S3 in the persist sink.
327            //
328            // Note that we need to fetch the probe LSN _after_ having created the replication
329            // slot, to make sure the fetched LSN will be included in the replication stream.
330            let probe_ts = (config.now_fn)().into();
331            let max_lsn = mz_postgres_util::fetch_max_lsn(
332                &*metadata_client,
333                connection.publication_details.get_is_physical_replica(),
334            )
335            .await?;
336            let probe = Probe {
337                probe_ts,
338                upstream_frontier: Antichain::from_elem(MzOffset::from(max_lsn)),
339            };
340            probe_output.give(&probe_cap[0], probe);
341
342            let mut rewinds = BTreeMap::new();
343            while let Some(event) = rewind_input.next().await {
344                if let AsyncEvent::Data(_, data) = event {
345                    for req in data {
346                        if resume_lsn > req.snapshot_lsn + 1 {
347                            let err = DefiniteError::SlotCompactedPastResumePoint(
348                                req.snapshot_lsn + 1,
349                                resume_lsn,
350                            );
351                            // If the replication stream cannot be obtained from the resume point there is nothing
352                            // else to do. These errors are not retractable.
353                            for (oid, outputs) in table_info.iter() {
354                                for output_index in outputs.keys() {
355                                    // We pick `u64::MAX` as the LSN which will (in practice) never conflict
356                                    // any previously revealed portions of the TVC.
357                                    let update = (
358                                        (
359                                            *oid,
360                                            *output_index,
361                                            Err(DataflowError::from(err.clone())),
362                                        ),
363                                        MzOffset::from(u64::MAX),
364                                        Diff::ONE,
365                                    );
366                                    let size = update.fuel_size();
367                                    data_output
368                                        .give_fueled(&data_cap_set[0], update, size)
369                                        .await;
370                                }
371                            }
372                            definite_error_handle.give(
373                                &definite_error_cap_set[0],
374                                ReplicationError::Definite(Rc::new(err)),
375                            );
376                            return Ok(());
377                        }
378                        rewinds.insert(req.output_index, req);
379                    }
380                }
381            }
382            trace!(%id, "timely-{worker_id} pending rewinds {rewinds:?}");
383
384            let mut committed_uppers = pin!(committed_uppers);
385
386            let stream_result = raw_stream(
387                &config,
388                replication_client,
389                Arc::clone(&metadata_client),
390                &connection.publication_details.slot,
391                &connection.publication_details.timeline_id,
392                &connection.publication,
393                resume_lsn,
394                committed_uppers.as_mut(),
395                &probe_output,
396                &probe_cap[0],
397                connection.publication_details.get_is_physical_replica(),
398            )
399            .await?;
400
401            let stream = match stream_result {
402                Ok(stream) => stream,
403                Err(err) => {
404                    // If the replication stream cannot be obtained in a definite way there is
405                    // nothing else to do. These errors are not retractable.
406                    for (oid, outputs) in table_info.iter() {
407                        for output_index in outputs.keys() {
408                            // We pick `u64::MAX` as the LSN which will (in practice) never conflict
409                            // any previously revealed portions of the TVC.
410                            let update = (
411                                (*oid, *output_index, Err(DataflowError::from(err.clone()))),
412                                MzOffset::from(u64::MAX),
413                                Diff::ONE,
414                            );
415                            let size = update.fuel_size();
416                            data_output
417                                .give_fueled(&data_cap_set[0], update, size)
418                                .await;
419                        }
420                    }
421
422                    definite_error_handle.give(
423                        &definite_error_cap_set[0],
424                        ReplicationError::Definite(Rc::new(err)),
425                    );
426                    return Ok(());
427                }
428            };
429            let mut stream = pin!(stream.peekable());
430
431            // Run the periodic schema validation on a separate task using a separate client,
432            // to prevent it from blocking the replication reading progress.
433            let ssh_tunnel_manager = &config.config.connection_context.ssh_tunnel_manager;
434            let client = connection_config
435                .connect("schema validation", ssh_tunnel_manager)
436                .await?;
437            let mut schema_errors = spawn_schema_validator(
438                client,
439                &config,
440                connection.publication.clone(),
441                table_info.clone(),
442                connection.publication_details.get_is_physical_replica(),
443            );
444
445            // Instead of downgrading the capability for every transaction we process we only do it
446            // if we're about to yield, which is checked at the bottom of the loop. This avoids
447            // creating excessive progress tracking traffic when there are multiple small
448            // transactions ready to go.
449            let mut data_upper = resume_lsn;
450            while let Some(event) = stream.as_mut().next().await {
451                use LogicalReplicationMessage::*;
452                use ReplicationMessage::*;
453                match event {
454                    Ok(XLogData(data)) => match data.data() {
455                        Begin(begin) => {
456                            let commit_lsn = MzOffset::from(begin.final_lsn());
457
458                            let mut tx = pin!(extract_transaction(
459                                stream.by_ref(),
460                                &*metadata_client,
461                                commit_lsn,
462                                &mut table_info,
463                                &metrics,
464                                &connection.publication,
465                            ));
466
467                            trace!(
468                                %id,
469                                "timely-{worker_id} extracting transaction \
470                                    at {commit_lsn}"
471                            );
472                            assert!(
473                                data_upper <= commit_lsn,
474                                "new_upper={data_upper} tx_lsn={commit_lsn}",
475                            );
476                            data_upper = commit_lsn + 1;
477                            while let Some((oid, output_index, event, diff)) = tx.try_next().await?
478                            {
479                                let event = event.map_err(Into::into);
480                                let data = (oid, output_index, event);
481                                if let Some(req) = rewinds.get(&output_index) {
482                                    if commit_lsn <= req.snapshot_lsn {
483                                        let update = (data.clone(), MzOffset::from(0), -diff);
484                                        let size = update.fuel_size();
485                                        data_output
486                                            .give_fueled(&data_cap_set[0], update, size)
487                                            .await;
488                                    }
489                                }
490                                let update = (data, commit_lsn, diff);
491                                let size = update.fuel_size();
492                                data_output
493                                    .give_fueled(&data_cap_set[0], update, size)
494                                    .await;
495                            }
496                        }
497                        _ => return Err(TransientError::BareTransactionEvent),
498                    },
499                    Ok(PrimaryKeepAlive(keepalive)) => {
500                        trace!( %id,
501                            "timely-{worker_id} received keepalive lsn={}",
502                            keepalive.wal_end()
503                        );
504
505                        // Take the opportunity to report any schema validation errors.
506                        while let Ok(error) = schema_errors.try_recv() {
507                            use SchemaValidationError::*;
508                            match error {
509                                Postgres(PostgresError::PublicationMissing(publication)) => {
510                                    let err = DefiniteError::PublicationDropped(publication);
511                                    for (oid, outputs) in table_info.iter() {
512                                        for output_index in outputs.keys() {
513                                            let update = (
514                                                (
515                                                    *oid,
516                                                    *output_index,
517                                                    Err(DataflowError::from(err.clone())),
518                                                ),
519                                                data_cap_set[0].time().clone(),
520                                                Diff::ONE,
521                                            );
522                                            let size = update.fuel_size();
523                                            data_output
524                                                .give_fueled(&data_cap_set[0], update, size)
525                                                .await;
526                                        }
527                                    }
528                                    definite_error_handle.give(
529                                        &definite_error_cap_set[0],
530                                        ReplicationError::Definite(Rc::new(err)),
531                                    );
532                                    return Ok(());
533                                }
534                                Postgres(pg_error) => Err(TransientError::from(pg_error))?,
535                                PhysicalReplicaChanged { expected, actual } => {
536                                    // The upstream is no longer the kind of node the
537                                    // source was created against (e.g. a physical
538                                    // replica was promoted to a primary). Logical
539                                    // decoding cannot safely continue, so stall with a
540                                    // definite, non-retryable error.
541                                    let err =
542                                        DefiniteError::InvalidPhysicalReplica { expected, actual };
543                                    for (oid, outputs) in table_info.iter() {
544                                        for output_index in outputs.keys() {
545                                            let update = (
546                                                (
547                                                    *oid,
548                                                    *output_index,
549                                                    Err(DataflowError::from(err.clone())),
550                                                ),
551                                                // We don't have a clean way to align on when the replica changed so jump straight to u64::MAX to avoid conflicts.
552                                                MzOffset::from(u64::MAX),
553                                                Diff::ONE,
554                                            );
555                                            let size = update.fuel_size();
556                                            data_output
557                                                .give_fueled(&data_cap_set[0], update, size)
558                                                .await;
559                                        }
560                                    }
561                                    definite_error_handle.give(
562                                        &definite_error_cap_set[0],
563                                        ReplicationError::Definite(Rc::new(err)),
564                                    );
565                                    return Ok(());
566                                }
567                                Schema {
568                                    oid,
569                                    output_index,
570                                    error,
571                                } => {
572                                    let table = table_info.get_mut(&oid).unwrap();
573                                    if table.remove(&output_index).is_none() {
574                                        continue;
575                                    }
576
577                                    let update = (
578                                        (oid, output_index, Err(error.into())),
579                                        data_cap_set[0].time().clone(),
580                                        Diff::ONE,
581                                    );
582                                    let size = update.fuel_size();
583                                    data_output
584                                        .give_fueled(&data_cap_set[0], update, size)
585                                        .await;
586                                }
587                            }
588                        }
589                        data_upper = std::cmp::max(data_upper, keepalive.wal_end().into());
590                    }
591                    Ok(_) => return Err(TransientError::UnknownReplicationMessage),
592                    Err(err) => return Err(err),
593                }
594
595                let will_yield = stream.as_mut().peek().now_or_never().is_none();
596                if will_yield {
597                    trace!(%id, "timely-{worker_id} yielding at lsn={data_upper}");
598                    rewinds.retain(|_, req| data_upper <= req.snapshot_lsn);
599                    // As long as there are pending rewinds we can't downgrade our data capability
600                    // since we must be able to produce data at offset 0.
601                    if rewinds.is_empty() {
602                        data_cap_set.downgrade([&data_upper]);
603                    }
604                }
605            }
606            // We never expect the replication stream to gracefully end
607            Err(TransientError::ReplicationEOF)
608        }))
609    });
610
611    // We now process the slot updates and apply the cast expressions
612    let mut final_row = Row::default();
613    let mut datum_vec = DatumVec::new();
614    let mut next_worker = (0..u64::cast_from(scope.peers()))
615        // Round robin on 1000-records basis to avoid creating tiny containers when there are a
616        // small number of updates and a large number of workers.
617        .flat_map(|w| std::iter::repeat_n(w, 1000))
618        .cycle();
619    let round_robin = Exchange::new(move |_| next_worker.next().unwrap());
620    let replication_updates = data_stream
621        .unary(round_robin, "PgCastReplicationRows", |_, _| {
622            move |input, output| {
623                input.for_each_time(|time, data| {
624                    let mut session = output.session(&time);
625                    for ((oid, output_index, event), time, diff) in
626                        data.flat_map(|data| data.drain(..))
627                    {
628                        let output = &table_info
629                            .get(&oid)
630                            .and_then(|outputs| outputs.get(&output_index))
631                            .expect("table_info contains all outputs");
632                        let event = event.and_then(|row| {
633                            let datums = datum_vec.borrow_with(&row);
634                            super::cast_row(&output.casts, &datums, &mut final_row)?;
635                            Ok(SourceMessage {
636                                key: Row::default(),
637                                value: final_row.clone(),
638                                metadata: Row::default(),
639                            })
640                        });
641
642                        session.give(((output_index, event), time, diff));
643                    }
644                });
645            }
646        })
647        .as_collection();
648
649    let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
650
651    (
652        replication_updates,
653        probe_stream,
654        errors,
655        button.press_on_drop(),
656    )
657}
658
659/// Produces the logical replication stream while taking care of regularly sending standby
660/// keepalive messages with the provided `uppers` stream.
661///
662/// The returned stream will contain all transactions that whose commit LSN is beyond `resume_lsn`.
663async fn raw_stream<'a>(
664    config: &'a RawSourceCreationConfig,
665    replication_client: Client,
666    metadata_client: Arc<Client>,
667    slot: &'a str,
668    timeline_id: &'a Option<u64>,
669    publication: &'a str,
670    resume_lsn: MzOffset,
671    uppers: impl futures::Stream<Item = Antichain<MzOffset>> + 'a,
672    probe_output: &'a AsyncOutputHandle<MzOffset, CapacityContainerBuilder<Vec<Probe<MzOffset>>>>,
673    probe_cap: &'a Capability<MzOffset>,
674    is_physical_replica: bool,
675) -> Result<
676    Result<impl AsyncStream<Item = Result<LogicalReplMsg, TransientError>> + 'a, DefiniteError>,
677    TransientError,
678> {
679    if let Err(err) = ensure_publication_exists(&*metadata_client, publication).await? {
680        // If the publication gets deleted there is nothing else to do. These errors
681        // are not retractable.
682        return Ok(Err(err));
683    }
684
685    // Ensure the upstream server's physical replica status hasn't changed since the source was
686    // created. We use the metadata client here for the same reason as "SHOW wal_sender_timeout" below.
687    //
688    // This runs before the timeline ID check on purpose. Promoting a physical replica to a primary
689    // both flips pg_is_in_recovery() and switches the timeline, so either check could fire. Running
690    // this one first means a promotion always surfaces as the specific InvalidPhysicalReplica error
691    // (rather than the more generic timeline mismatch), which is the clearer signal for an operator.
692    if let Err(err) = ensure_physical_replica(&*metadata_client, is_physical_replica).await? {
693        return Ok(Err(err));
694    }
695
696    // Skip the timeline ID check for sources without a known timeline ID
697    // (sources created before the timeline ID was added to the source details)
698    if let Some(expected_timeline_id) = timeline_id {
699        if let Err(err) = ensure_replication_timeline_id(
700            &replication_client,
701            expected_timeline_id,
702            config.config.config_set(),
703        )
704        .await?
705        {
706            return Ok(Err(err));
707        }
708    }
709
710    // How often a proactive standby status update message should be sent to the server.
711    //
712    // The upstream will periodically request status updates by setting the keepalive's reply field
713    // value to 1. However, we cannot rely on these messages arriving on time. For example, when
714    // the upstream is sending a big transaction its keepalive messages are queued and can be
715    // delayed arbitrarily.
716    //
717    // See: <https://www.postgresql.org/message-id/CAMsr+YE2dSfHVr7iEv1GSPZihitWX-PMkD9QALEGcTYa+sdsgg@mail.gmail.com>
718    //
719    // For this reason we query the server's timeout value and proactively send a keepalive at
720    // twice the frequency to have a healthy margin from the deadline.
721    //
722    // Note: We must use the metadata client here which is NOT in replication mode. Some Aurora
723    // Postgres versions disallow SHOW commands from within replication connection.
724    // See: https://github.com/readysettech/readyset/discussions/28#discussioncomment-4405671
725    let row = simple_query_opt(&*metadata_client, sql!("SHOW wal_sender_timeout;"))
726        .await?
727        .unwrap();
728    let wal_sender_timeout = match row.get("wal_sender_timeout") {
729        // When this parameter is zero the timeout mechanism is disabled
730        Some("0") => None,
731        Some(value) => Some(
732            mz_repr::adt::interval::Interval::from_str(value)
733                .unwrap()
734                .duration()
735                .unwrap(),
736        ),
737        None => panic!("ubiquitous parameter missing"),
738    };
739
740    // This interval controls the cadence at which we send back status updates and, crucially,
741    // request PrimaryKeepAlive messages. PrimaryKeepAlive messages drive the frontier forward in
742    // the absence of data updates and we don't want a large `wal_sender_timeout` value to slow us
743    // down. For this reason the feedback interval is set to one second, or less if the
744    // wal_sender_timeout is less than 2 seconds.
745    let feedback_interval = match wal_sender_timeout {
746        Some(t) => std::cmp::min(Duration::from_secs(1), t.checked_div(2).unwrap()),
747        None => Duration::from_secs(1),
748    };
749
750    let mut feedback_timer = tokio::time::interval(feedback_interval);
751    // 'Delay' ensures we always tick at least 'feedback_interval'.
752    feedback_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
753
754    // Postgres will return all transactions that commit *at or after* after the provided LSN,
755    // following the timely upper semantics.
756    let lsn = PgLsn::from(resume_lsn.offset);
757    let query = sql!(
758        "START_REPLICATION SLOT {} LOGICAL {} (\"proto_version\" '1', \"publication_names\" {})",
759        Sql::ident(slot),
760        Sql::raw_unchecked(lsn.to_string()),
761        Sql::literal(publication)
762    );
763    let copy_stream = match replication_client.copy_both_simple(query.as_str()).await {
764        Ok(copy_stream) => copy_stream,
765        Err(err) if err.code() == Some(&SqlState::OBJECT_NOT_IN_PREREQUISITE_STATE) => {
766            return Ok(Err(DefiniteError::InvalidReplicationSlot));
767        }
768        Err(err) => return Err(err.into()),
769    };
770
771    // According to the documentation [1] we must check that the slot LSN matches our
772    // expectations otherwise we risk getting silently fast-forwarded to a future LSN. In order
773    // to avoid a TOCTOU issue we must do this check after starting the replication stream. We
774    // cannot use the replication client to do that because it's already in CopyBoth mode.
775    // [1] https://www.postgresql.org/docs/15/protocol-replication.html#PROTOCOL-REPLICATION-START-REPLICATION-SLOT-LOGICAL
776    let slot_metadata = super::fetch_slot_metadata(
777        &*metadata_client,
778        slot,
779        mz_storage_types::dyncfgs::PG_FETCH_SLOT_RESUME_LSN_INTERVAL
780            .get(config.config.config_set()),
781    )
782    .await?;
783    let min_resume_lsn = slot_metadata.confirmed_flush_lsn;
784    tracing::info!(
785        %config.id,
786        "started replication using backend PID={:?}. wal_sender_timeout={:?}",
787        slot_metadata.active_pid, wal_sender_timeout
788    );
789
790    let (probe_tx, mut probe_rx) = watch::channel(None);
791    let timestamp_interval = config.timestamp_interval;
792    let now_fn = config.now_fn.clone();
793    let max_lsn_task_handle =
794        mz_ore::task::spawn(|| format!("pg_current_wal_lsn:{}", config.id), async move {
795            let mut probe_ticker = probe::Ticker::new(move || timestamp_interval, now_fn);
796
797            while !probe_tx.is_closed() {
798                let probe_ts = probe_ticker.tick().await;
799                let probe_or_err =
800                    mz_postgres_util::fetch_max_lsn(&*metadata_client, is_physical_replica)
801                        .await
802                        .map(|lsn| Probe {
803                            probe_ts,
804                            upstream_frontier: Antichain::from_elem(MzOffset::from(lsn)),
805                        });
806                let _ = probe_tx.send(Some(probe_or_err));
807            }
808        })
809        .abort_on_drop();
810
811    let stream = async_stream::try_stream!({
812        // Ensure we don't pre-drop the task
813        let _max_lsn_task_handle = max_lsn_task_handle;
814
815        // ensure we don't drop the replication client!
816        let _replication_client = replication_client;
817
818        let mut uppers = pin!(uppers);
819        let mut last_committed_upper = resume_lsn;
820
821        let mut stream = pin!(LogicalReplicationStream::new(copy_stream));
822
823        if !(resume_lsn == MzOffset::from(0) || min_resume_lsn <= resume_lsn) {
824            let err = TransientError::OvercompactedReplicationSlot {
825                available_lsn: min_resume_lsn,
826                requested_lsn: resume_lsn,
827            };
828            error!("timely-{} ({}) {err}", config.worker_id, config.id);
829            Err(err)?;
830        }
831
832        loop {
833            tokio::select! {
834                Some(next_message) = stream.next() => match next_message {
835                    Ok(ReplicationMessage::XLogData(data)) => {
836                        yield ReplicationMessage::XLogData(data);
837                        Ok(())
838                    }
839                    Ok(ReplicationMessage::PrimaryKeepAlive(keepalive)) => {
840                        yield ReplicationMessage::PrimaryKeepAlive(keepalive);
841                        Ok(())
842                    }
843                    Err(err) => Err(err.into()),
844                    _ => Err(TransientError::UnknownReplicationMessage),
845                },
846                _ = feedback_timer.tick() => {
847                    let ts: i64 = PG_EPOCH.elapsed().unwrap().as_micros().try_into().unwrap();
848                    let lsn = PgLsn::from(last_committed_upper.offset);
849                    trace!("timely-{} ({}) sending keepalive {lsn:?}", config.worker_id, config.id);
850                    // Postgres only sends PrimaryKeepAlive messages when *it* wants a reply, which
851                    // happens when out status update is late. Since we send them proactively this
852                    // may never happen. It is therefore *crucial* that we set the last parameter
853                    // (the reply flag) to 1 here. This will cause the upstream server to send us a
854                    // PrimaryKeepAlive message promptly which will give us frontier advancement
855                    // information in the absence of data updates.
856                    let res = stream.as_mut().standby_status_update(lsn, lsn, lsn, ts, 1).await;
857                    res.map_err(|e| e.into())
858                },
859                Some(upper) = uppers.next() => match upper.into_option() {
860                    Some(lsn) => {
861                        if last_committed_upper < lsn {
862                            last_committed_upper = lsn;
863                            for stat in config.statistics.values() {
864                                stat.set_offset_committed(last_committed_upper.offset);
865                            }
866                        }
867                        Ok(())
868                    }
869                    None => Ok(()),
870                },
871                Ok(()) = probe_rx.changed() => match &*probe_rx.borrow() {
872                    Some(Ok(probe)) => {
873                        if let Some(offset_known) = probe.upstream_frontier.as_option() {
874                            for stat in config.statistics.values() {
875                                stat.set_offset_known(offset_known.offset);
876                            }
877                        }
878                        probe_output.give(probe_cap, probe);
879                        Ok(())
880                    },
881                    Some(Err(err)) => Err(anyhow::anyhow!("{err}").into()),
882                    None => Ok(()),
883                },
884                else => return
885            }?;
886        }
887    });
888    Ok(Ok(stream))
889}
890
891/// The outputs of relation `rel` that a message committed at `commit_lsn` applies to.
892fn outputs_at(
893    table_info: &BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
894    rel: u32,
895    commit_lsn: MzOffset,
896) -> impl Iterator<Item = (&usize, &SourceOutputInfo)> {
897    table_info
898        .get(&rel)
899        .into_iter()
900        .flatten()
901        .filter(move |(_, info)| !info.ignores(commit_lsn))
902}
903
904/// Extracts a single transaction from the replication stream delimited by a BEGIN and COMMIT
905/// message. The BEGIN message must have already been consumed from the stream before calling this
906/// function.
907fn extract_transaction<'a>(
908    stream: impl AsyncStream<Item = Result<LogicalReplMsg, TransientError>> + 'a,
909    metadata_client: &'a Client,
910    commit_lsn: MzOffset,
911    table_info: &'a mut BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
912    metrics: &'a PgSourceMetrics,
913    publication: &'a str,
914) -> impl AsyncStream<Item = Result<DecodedRow, TransientError>> + 'a {
915    use LogicalReplicationMessage::*;
916    let mut row = Row::default();
917    async_stream::try_stream!({
918        let mut stream = pin!(stream);
919        metrics.transactions.inc();
920        metrics.lsn.set(commit_lsn.offset);
921        while let Some(event) = stream.try_next().await? {
922            // We can ignore keepalive messages while processing a transaction because the
923            // commit_lsn will drive progress.
924            let message = match event {
925                ReplicationMessage::XLogData(data) => data.into_data(),
926                ReplicationMessage::PrimaryKeepAlive(_) => {
927                    metrics.ignored.inc();
928                    continue;
929                }
930                _ => Err(TransientError::UnknownReplicationMessage)?,
931            };
932            metrics.total.inc();
933            match message {
934                Insert(body) if !table_info.contains_key(&body.rel_id()) => metrics.ignored.inc(),
935                Update(body) if !table_info.contains_key(&body.rel_id()) => metrics.ignored.inc(),
936                Delete(body) if !table_info.contains_key(&body.rel_id()) => metrics.ignored.inc(),
937                Relation(body) if !table_info.contains_key(&body.rel_id()) => metrics.ignored.inc(),
938                Insert(body) => {
939                    metrics.inserts.inc();
940                    let rel = body.rel_id();
941                    for (output, info) in outputs_at(table_info, rel, commit_lsn) {
942                        let tuple_data = body.tuple().tuple_data();
943                        let Some(ref projection) = info.projection else {
944                            panic!("missing projection for {rel}");
945                        };
946                        let datums = projection.iter().map(|idx| &tuple_data[*idx]);
947                        let row = unpack_tuple(datums, &mut row);
948                        yield (rel, *output, row, Diff::ONE);
949                    }
950                }
951                Update(body) => match body.old_tuple() {
952                    Some(old_tuple) => {
953                        metrics.updates.inc();
954                        let new_tuple = body.new_tuple();
955                        let rel = body.rel_id();
956                        for (output, info) in outputs_at(table_info, rel, commit_lsn) {
957                            let Some(ref projection) = info.projection else {
958                                panic!("missing projection for {rel}");
959                            };
960                            let old_tuple =
961                                projection.iter().map(|idx| &old_tuple.tuple_data()[*idx]);
962                            // If the new tuple contains unchanged toast values we reference the old ones
963                            let new_tuple = std::iter::zip(
964                                projection.iter().map(|idx| &new_tuple.tuple_data()[*idx]),
965                                old_tuple.clone(),
966                            )
967                            .map(|(new, old)| match new {
968                                TupleData::UnchangedToast => old,
969                                _ => new,
970                            });
971                            let old_row = unpack_tuple(old_tuple, &mut row);
972                            let new_row = unpack_tuple(new_tuple, &mut row);
973
974                            yield (rel, *output, old_row, Diff::MINUS_ONE);
975                            yield (rel, *output, new_row, Diff::ONE);
976                        }
977                    }
978                    None => {
979                        let rel = body.rel_id();
980                        for (output, _) in outputs_at(table_info, rel, commit_lsn) {
981                            yield (
982                                rel,
983                                *output,
984                                Err(DefiniteError::DefaultReplicaIdentity),
985                                Diff::ONE,
986                            );
987                        }
988                    }
989                },
990                Delete(body) => match body.old_tuple() {
991                    Some(old_tuple) => {
992                        metrics.deletes.inc();
993                        let rel = body.rel_id();
994                        for (output, info) in outputs_at(table_info, rel, commit_lsn) {
995                            let Some(ref projection) = info.projection else {
996                                panic!("missing projection for {rel}");
997                            };
998                            let datums = projection.iter().map(|idx| &old_tuple.tuple_data()[*idx]);
999                            let row = unpack_tuple(datums, &mut row);
1000                            yield (rel, *output, row, Diff::MINUS_ONE);
1001                        }
1002                    }
1003                    None => {
1004                        let rel = body.rel_id();
1005                        for (output, _) in outputs_at(table_info, rel, commit_lsn) {
1006                            yield (
1007                                rel,
1008                                *output,
1009                                Err(DefiniteError::DefaultReplicaIdentity),
1010                                Diff::ONE,
1011                            );
1012                        }
1013                    }
1014                },
1015                Relation(body) => {
1016                    let rel_id = body.rel_id();
1017                    if let Some(outputs) = table_info.get_mut(&body.rel_id()) {
1018                        // Because the replication stream doesn't include columns' attnums, we need
1019                        // to check the current local schema against the current remote schema to
1020                        // ensure e.g. we haven't received a schema update with the same terminal
1021                        // column name which is actually a different column.
1022                        let upstream_info = mz_postgres_util::publication_info(
1023                            metadata_client,
1024                            publication,
1025                            Some(&[rel_id]),
1026                        )
1027                        .await?;
1028
1029                        let mut schema_errors = vec![];
1030
1031                        outputs.retain(|output_index, info| {
1032                            match verify_schema(rel_id, info, &upstream_info) {
1033                                Ok(()) => true,
1034                                Err(err) => {
1035                                    schema_errors.push((
1036                                        rel_id,
1037                                        *output_index,
1038                                        Err(err),
1039                                        Diff::ONE,
1040                                    ));
1041                                    false
1042                                }
1043                            }
1044                        });
1045                        // Recalculate projection vector for the retained valid outputs. Here we
1046                        // must use the column names in the RelationBody message and not the
1047                        // upstream_info obtained above, since that one represents the current
1048                        // schema upstream which may be many versions head of the one we're about
1049                        // to receive after this Relation message.
1050                        let column_positions: BTreeMap<_, _> = body
1051                            .columns()
1052                            .iter()
1053                            .enumerate()
1054                            .map(|(idx, col)| (col.name().unwrap(), idx))
1055                            .collect();
1056                        for info in outputs.values_mut() {
1057                            let projection: Option<Vec<_>> = info
1058                                .desc
1059                                .columns
1060                                .iter()
1061                                .map(|col| column_positions.get(&*col.name).copied())
1062                                .collect();
1063                            match projection {
1064                                Some(projection) => info.projection = Some(projection),
1065                                // This message describes a schema from before the output was
1066                                // created, which need not cover `desc`. Retaining the previous
1067                                // projection is safe: messages at these LSNs are ignored, and the
1068                                // schema change that made the columns diverge invalidates the
1069                                // upstream's relation cache, so another Relation message arrives
1070                                // ahead of the first message that isn't ignored.
1071                                None if info.ignores(commit_lsn) => (),
1072                                None => panic!(
1073                                    "Relation message for {rel_id} at {commit_lsn} does not \
1074                                     cover the captured schema of {}",
1075                                    info.desc.name
1076                                ),
1077                            }
1078                        }
1079                        for schema_error in schema_errors {
1080                            yield schema_error;
1081                        }
1082                    }
1083                }
1084                Truncate(body) => {
1085                    for &rel_id in body.rel_ids() {
1086                        if let Some(outputs) = table_info.get_mut(&rel_id) {
1087                            let mut ignored = BTreeMap::new();
1088                            for (output, info) in std::mem::take(outputs) {
1089                                if info.ignores(commit_lsn) {
1090                                    ignored.insert(output, info);
1091                                    continue;
1092                                }
1093                                yield (
1094                                    rel_id,
1095                                    output,
1096                                    Err(DefiniteError::TableTruncated),
1097                                    Diff::ONE,
1098                                );
1099                            }
1100                            *outputs = ignored;
1101                        }
1102                    }
1103                }
1104                Commit(body) => {
1105                    if commit_lsn != body.commit_lsn().into() {
1106                        Err(TransientError::InvalidTransaction)?
1107                    }
1108                    return;
1109                }
1110                // TODO: We should handle origin messages and emit an error as they indicate that
1111                // the upstream performed a point in time restore so all bets are off about the
1112                // continuity of the stream.
1113                Origin(_) | Type(_) => metrics.ignored.inc(),
1114                Begin(_) => Err(TransientError::NestedTransaction)?,
1115                // The enum is marked as non_exhaustive. Better to be conservative
1116                _ => Err(TransientError::UnknownLogicalReplicationMessage)?,
1117            }
1118        }
1119        Err(TransientError::ReplicationEOF)?;
1120    })
1121}
1122
1123/// Unpacks an iterator of TupleData into a list of nullable bytes or an error if this can't be
1124/// done.
1125#[inline]
1126fn unpack_tuple<'a, I>(tuple_data: I, row: &mut Row) -> Result<Row, DefiniteError>
1127where
1128    I: IntoIterator<Item = &'a TupleData>,
1129    I::IntoIter: ExactSizeIterator,
1130{
1131    let iter = tuple_data.into_iter();
1132    let mut packer = row.packer();
1133    for data in iter {
1134        let datum = match data {
1135            TupleData::Text(bytes) => super::decode_utf8_text(bytes)?,
1136            TupleData::Null => Datum::Null,
1137            TupleData::UnchangedToast => return Err(DefiniteError::MissingToast),
1138            TupleData::Binary(_) => return Err(DefiniteError::UnexpectedBinaryData),
1139        };
1140        packer.push(datum);
1141    }
1142    Ok(row.clone())
1143}
1144
1145/// Ensures the publication exists on the server. It returns an outer transient error in case of
1146/// connection issues and an inner definite error if the publication is dropped.
1147async fn ensure_publication_exists(
1148    client: &Client,
1149    publication: &str,
1150) -> Result<Result<(), DefiniteError>, TransientError> {
1151    // Figure out the last written LSN and then add one to convert it into an upper.
1152    let result = query_opt(
1153        &**client,
1154        sql!("SELECT 1 FROM pg_publication WHERE pubname = $1;"),
1155        &[&publication],
1156    )
1157    .await?;
1158    match result {
1159        Some(_) => Ok(Ok(())),
1160        None => Ok(Err(DefiniteError::PublicationDropped(
1161            publication.to_owned(),
1162        ))),
1163    }
1164}
1165
1166/// Ensure the active replication timeline_id matches the one we expect such that we can safely
1167/// resume replication. It returns an outer transient error in case of
1168/// connection issues and an inner definite error if the timeline id does not match.
1169async fn ensure_replication_timeline_id(
1170    replication_client: &Client,
1171    expected_timeline_id: &u64,
1172    config_set: &ConfigSet,
1173) -> Result<Result<(), DefiniteError>, TransientError> {
1174    let timeline_id = mz_postgres_util::get_timeline_id(replication_client).await?;
1175    if timeline_id == *expected_timeline_id {
1176        Ok(Ok(()))
1177    } else {
1178        if PG_SOURCE_VALIDATE_TIMELINE.get(config_set) {
1179            Ok(Err(DefiniteError::InvalidTimelineId {
1180                expected: *expected_timeline_id,
1181                actual: timeline_id,
1182            }))
1183        } else {
1184            tracing::warn!(
1185                "Timeline ID mismatch ignored: expected={expected_timeline_id} actual={timeline_id}"
1186            );
1187            Ok(Ok(()))
1188        }
1189    }
1190}
1191
1192async fn ensure_physical_replica(
1193    metadata_client: &Client,
1194    expected_is_physical_replica: bool,
1195) -> Result<Result<(), DefiniteError>, TransientError> {
1196    let is_physical_replica = mz_postgres_util::get_is_in_recovery(metadata_client).await?;
1197    if is_physical_replica == expected_is_physical_replica {
1198        Ok(Ok(()))
1199    } else {
1200        Ok(Err(DefiniteError::InvalidPhysicalReplica {
1201            expected: expected_is_physical_replica,
1202            actual: is_physical_replica,
1203        }))
1204    }
1205}
1206
1207enum SchemaValidationError {
1208    Postgres(PostgresError),
1209    Schema {
1210        oid: u32,
1211        output_index: usize,
1212        error: DefiniteError,
1213    },
1214    /// The upstream's physical-replica status changed out from under us (e.g. a
1215    /// physical replica was promoted to a primary). `expected` is the status the
1216    /// source was created against; `actual` is what the upstream reports now.
1217    PhysicalReplicaChanged {
1218        expected: bool,
1219        actual: bool,
1220    },
1221}
1222
1223fn spawn_schema_validator(
1224    client: Client,
1225    config: &RawSourceCreationConfig,
1226    publication: String,
1227    table_info: BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
1228    is_physical_replica: bool,
1229) -> mpsc::UnboundedReceiver<SchemaValidationError> {
1230    let (tx, rx) = mpsc::unbounded_channel();
1231    let source_id = config.id;
1232    let config_set = Arc::clone(config.config.config_set());
1233
1234    mz_ore::task::spawn(|| format!("schema-validator:{}", source_id), async move {
1235        while !tx.is_closed() {
1236            trace!(%source_id, "validating schemas");
1237
1238            let validation_start = Instant::now();
1239
1240            // Detect a physical replica being promoted to a primary (or vice
1241            // versa) while the stream is live. The startup check in raw_stream
1242            // only catches this across a restart, so we re-check periodically
1243            // here to surface a definite error proactively.
1244            match mz_postgres_util::get_is_in_recovery(&*client).await {
1245                Ok(actual) if actual != is_physical_replica => {
1246                    let _ = tx.send(SchemaValidationError::PhysicalReplicaChanged {
1247                        expected: is_physical_replica,
1248                        actual,
1249                    });
1250                }
1251                Ok(_) => {}
1252                Err(error) => {
1253                    let _ = tx.send(SchemaValidationError::Postgres(error));
1254                    continue;
1255                }
1256            }
1257
1258            let upstream_info = match mz_postgres_util::publication_info(
1259                &*client,
1260                &publication,
1261                Some(&table_info.keys().copied().collect::<Vec<_>>()),
1262            )
1263            .await
1264            {
1265                Ok(info) => info,
1266                Err(error) => {
1267                    let _ = tx.send(SchemaValidationError::Postgres(error));
1268                    continue;
1269                }
1270            };
1271
1272            for (&oid, outputs) in table_info.iter() {
1273                for (&output_index, info) in outputs {
1274                    if let Err(error) = verify_schema(oid, info, &upstream_info) {
1275                        trace!(
1276                            %source_id,
1277                            "schema of output index {output_index} for oid {oid} invalid",
1278                        );
1279                        let _ = tx.send(SchemaValidationError::Schema {
1280                            oid,
1281                            output_index,
1282                            error,
1283                        });
1284                    } else {
1285                        trace!(
1286                            %source_id,
1287                            "schema of output index {output_index} for oid {oid} valid",
1288                        );
1289                    }
1290                }
1291            }
1292
1293            let interval = PG_SCHEMA_VALIDATION_INTERVAL.get(&config_set);
1294            let elapsed = validation_start.elapsed();
1295            let wait = interval.saturating_sub(elapsed);
1296            tokio::time::sleep(wait).await;
1297        }
1298    });
1299
1300    rx
1301}