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}