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