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}