1use std::collections::BTreeMap;
84use std::rc::Rc;
85use std::time::Duration;
86
87use differential_dataflow::AsCollection;
88use itertools::Itertools as _;
89use mz_expr::EvalError;
90use mz_ore::cast::CastFrom;
91use mz_ore::error::ErrorExt;
92use mz_postgres_util::desc::PostgresTableDesc;
93use mz_postgres_util::schema_change::SchemaChangeError;
94use mz_postgres_util::{Client, PostgresError, Sql, query_opt, simple_query_opt, sql};
95use mz_repr::{Datum, Diff, GlobalId, Row};
96use mz_storage_types::errors::{DataflowError, SourceError, SourceErrorDetails};
97use mz_storage_types::sources::casts::StorageScalarExpr;
98use mz_storage_types::sources::postgres::CastType;
99use mz_storage_types::sources::{
100 MzOffset, PostgresSourceConnection, SourceExport, SourceExportDetails, SourceTimestamp,
101};
102use mz_timely_util::builder_async::PressOnDropButton;
103use serde::{Deserialize, Serialize};
104use timely::container::CapacityContainerBuilder;
105use timely::dataflow::operators::Concat;
106use timely::dataflow::operators::core::Partition;
107use timely::dataflow::operators::vec::{Map, ToStream};
108use timely::dataflow::{Scope, StreamVec};
109use timely::progress::{Antichain, Timestamp};
110use tokio_postgres::error::SqlState;
111use tokio_postgres::types::PgLsn;
112
113use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
114use crate::source::types::{Probe, SourceRender, StackedCollection};
115use crate::source::{RawSourceCreationConfig, SourceMessage};
116
117mod replication;
118mod snapshot;
119
120impl SourceRender for PostgresSourceConnection {
121 type Time = MzOffset;
122
123 const STATUS_NAMESPACE: StatusNamespace = StatusNamespace::Postgres;
124
125 fn render<'scope>(
128 self,
129 scope: Scope<'scope, MzOffset>,
130 config: &RawSourceCreationConfig,
131 resume_uppers: impl futures::Stream<Item = Antichain<MzOffset>> + 'static,
132 _start_signal: impl std::future::Future<Output = ()> + 'static,
133 ) -> (
134 BTreeMap<
135 GlobalId,
136 StackedCollection<'scope, MzOffset, Result<SourceMessage, DataflowError>>,
137 >,
138 StreamVec<'scope, MzOffset, HealthStatusMessage>,
139 StreamVec<'scope, MzOffset, Probe<MzOffset>>,
140 Vec<PressOnDropButton>,
141 ) {
142 let mut table_info = BTreeMap::new();
144 for (idx, (id, export)) in config.source_exports.iter().enumerate() {
145 let SourceExport {
146 details,
147 storage_metadata: _,
148 data_config: _,
149 } = export;
150 let details = match details {
151 SourceExportDetails::Postgres(details) => details,
152 SourceExportDetails::None => continue,
154 _ => panic!("unexpected source export details: {:?}", details),
155 };
156 let desc = details.table.clone();
157 let casts = details.column_casts.clone();
158 let resume_upper = Antichain::from_iter(
159 config
160 .source_resume_uppers
161 .get(id)
162 .expect("all source exports must be present in source resume uppers")
163 .iter()
164 .map(MzOffset::decode_row),
165 );
166 let output = SourceOutputInfo {
167 desc,
168 projection: None,
169 casts,
170 resume_upper,
171 export_id: id.clone(),
172 initial_lsn: details.initial_lsn.unwrap_or_else(MzOffset::minimum),
173 };
174 table_info
175 .entry(output.desc.oid)
176 .or_insert_with(BTreeMap::new)
177 .insert(idx, output);
178 }
179
180 let metrics = config.metrics.get_postgres_source_metrics(config.id);
181
182 let (snapshot_updates, rewinds, slot_ready, snapshot_err, snapshot_token) =
183 snapshot::render(
184 scope.clone(),
185 config.clone(),
186 self.clone(),
187 table_info.clone(),
188 metrics.snapshot_metrics.clone(),
189 );
190
191 let (repl_updates, probe_stream, repl_err, repl_token) = replication::render(
192 scope.clone(),
193 config.clone(),
194 self,
195 table_info,
196 rewinds,
197 slot_ready,
198 resume_uppers,
199 metrics,
200 );
201
202 let updates = snapshot_updates.concat(repl_updates);
203 let partition_count = u64::cast_from(config.source_exports.len());
204 let data_streams: Vec<_> = updates
205 .inner
206 .partition::<CapacityContainerBuilder<_>, _, _>(
207 partition_count,
208 |((output, data), time, diff): (
209 (usize, Result<SourceMessage, DataflowError>),
210 MzOffset,
211 Diff,
212 )| {
213 let output = u64::cast_from(output);
214 (output, (data, time, diff))
215 },
216 );
217 let mut data_collections = BTreeMap::new();
218 for (id, data_stream) in config.source_exports.keys().zip_eq(data_streams) {
219 data_collections.insert(*id, data_stream.as_collection());
220 }
221
222 let export_ids = config.source_exports.keys().copied();
223 let health_init = export_ids
224 .map(Some)
225 .chain(std::iter::once(None))
226 .map(|id| HealthStatusMessage {
227 id,
228 namespace: Self::STATUS_NAMESPACE,
229 update: HealthStatusUpdate::Running,
230 })
231 .collect::<Vec<_>>()
232 .to_stream(scope);
233
234 let errs = snapshot_err.concat(repl_err).map(move |err| {
238 let err_string = err.display_with_causes().to_string();
240 let hint = match &err {
241 ReplicationError::Definite(err) => err.hint(),
242 ReplicationError::Transient(_) => None,
243 };
244 let update = HealthStatusUpdate::halting(err_string.clone(), hint);
245
246 let namespace = match err {
247 ReplicationError::Transient(err)
248 if matches!(
249 &*err,
250 TransientError::PostgresError(PostgresError::Ssh(_))
251 | TransientError::PostgresError(PostgresError::SshIo(_))
252 ) =>
253 {
254 StatusNamespace::Ssh
255 }
256 _ => Self::STATUS_NAMESPACE,
257 };
258
259 HealthStatusMessage {
260 id: None,
261 namespace: namespace.clone(),
262 update,
263 }
264 });
265
266 let health = health_init.concat(errs);
267
268 (
269 data_collections,
270 health,
271 probe_stream,
272 vec![snapshot_token, repl_token],
273 )
274 }
275}
276
277#[derive(Clone, Debug)]
278struct SourceOutputInfo {
279 desc: PostgresTableDesc,
281 projection: Option<Vec<usize>>,
285 casts: Vec<(CastType, StorageScalarExpr)>,
286 resume_upper: Antichain<MzOffset>,
287 export_id: GlobalId,
288 initial_lsn: MzOffset,
292}
293
294impl SourceOutputInfo {
295 fn ignores(&self, commit_lsn: MzOffset) -> bool {
306 commit_lsn < self.initial_lsn
307 }
308}
309
310#[derive(Clone, Debug, thiserror::Error)]
311pub enum ReplicationError {
312 #[error(transparent)]
313 Transient(#[from] Rc<TransientError>),
314 #[error(transparent)]
315 Definite(#[from] Rc<DefiniteError>),
316}
317
318#[derive(Debug, thiserror::Error)]
320pub enum TransientError {
321 #[error("replication slot mysteriously missing")]
322 MissingReplicationSlot,
323 #[error(
324 "slot overcompacted. Requested LSN {requested_lsn} but only LSNs >= {available_lsn} are available"
325 )]
326 OvercompactedReplicationSlot {
327 requested_lsn: MzOffset,
328 available_lsn: MzOffset,
329 },
330 #[error("replication slot already exists")]
331 ReplicationSlotAlreadyExists,
332 #[error("stream ended prematurely")]
333 ReplicationEOF,
334 #[error("unexpected replication message")]
335 UnknownReplicationMessage,
336 #[error("unexpected logical replication message")]
337 UnknownLogicalReplicationMessage,
338 #[error("received replication event outside of transaction")]
339 BareTransactionEvent,
340 #[error("lsn mismatch between BEGIN and COMMIT")]
341 InvalidTransaction,
342 #[error("BEGIN within existing BEGIN stream")]
343 NestedTransaction,
344 #[error("recoverable errors should crash the process during snapshots")]
345 SyntheticError,
346 #[error("sql client error")]
347 SQLClient(#[from] tokio_postgres::Error),
348 #[error(transparent)]
349 PostgresError(#[from] PostgresError),
350 #[error(transparent)]
351 Generic(#[from] anyhow::Error),
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
356pub enum DefiniteError {
357 #[error("slot compacted past snapshot point. snapshot consistent point={0} resume_lsn={1}")]
358 SlotCompactedPastResumePoint(MzOffset, MzOffset),
359 #[error("table was truncated")]
360 TableTruncated,
361 #[error("table was dropped")]
362 TableDropped,
363 #[error("publication {0:?} does not exist")]
364 PublicationDropped(String),
365 #[error("replication slot has been invalidated because it exceeded the maximum reserved size")]
366 InvalidReplicationSlot,
367 #[error("unexpected number of columns while parsing COPY output")]
368 MissingColumn,
369 #[error("failed to parse COPY protocol")]
370 InvalidCopyInput,
371 #[error(
372 "unsupported action: database restored from point-in-time backup. Expected timeline ID {expected} but got {actual}"
373 )]
374 InvalidTimelineId { expected: u64, actual: u64 },
375 #[error(
376 "unsupported action: upstream went back in time. Expected a snapshot at or after LSN {initial_lsn} but the snapshot was taken at {snapshot_lsn}"
377 )]
378 InvalidSnapshotLsn {
379 initial_lsn: MzOffset,
380 snapshot_lsn: MzOffset,
381 },
382 #[error(
383 "unsupported action: upstream physical replica status changed (e.g. a physical replica was promoted to a primary). Expected pg_is_in_recovery()={expected} but got {actual}"
384 )]
385 InvalidPhysicalReplica { expected: bool, actual: bool },
386 #[error(
387 "TOASTed value missing from old row. Did you forget to set REPLICA IDENTITY to FULL for your table?"
388 )]
389 MissingToast,
390 #[error(
391 "old row missing from replication stream. Did you forget to set REPLICA IDENTITY to FULL for your table?"
392 )]
393 DefaultReplicaIdentity,
394 #[error("{0}")]
395 IncompatibleSchema(SchemaChangeError),
396 #[error("invalid UTF8 string: {0:?}")]
397 InvalidUTF8(Vec<u8>),
398 #[error("failed to cast raw column: {0}")]
399 CastError(#[source] EvalError),
400 #[error("unexpected binary data in replication stream")]
401 UnexpectedBinaryData,
402}
403
404impl DefiniteError {
405 fn hint(&self) -> Option<String> {
406 match self {
407 DefiniteError::IncompatibleSchema(err) => err.hint(),
408 _ => None,
409 }
410 }
411}
412
413impl From<DefiniteError> for DataflowError {
414 fn from(err: DefiniteError) -> Self {
415 let m = err.to_string().into();
416 DataflowError::SourceError(Box::new(SourceError {
417 hint: err.hint().map(Into::into),
418 error: match &err {
419 DefiniteError::SlotCompactedPastResumePoint(_, _) => SourceErrorDetails::Other(m),
420 DefiniteError::TableTruncated => SourceErrorDetails::Other(m),
421 DefiniteError::TableDropped => SourceErrorDetails::Other(m),
422 DefiniteError::PublicationDropped(_) => SourceErrorDetails::Initialization(m),
423 DefiniteError::InvalidReplicationSlot => SourceErrorDetails::Initialization(m),
424 DefiniteError::MissingColumn => SourceErrorDetails::Other(m),
425 DefiniteError::InvalidCopyInput => SourceErrorDetails::Other(m),
426 DefiniteError::InvalidTimelineId { .. } => SourceErrorDetails::Initialization(m),
427 DefiniteError::InvalidSnapshotLsn { .. } => SourceErrorDetails::Initialization(m),
428 DefiniteError::InvalidPhysicalReplica { .. } => {
429 SourceErrorDetails::Initialization(m)
430 }
431 DefiniteError::MissingToast => SourceErrorDetails::Other(m),
432 DefiniteError::DefaultReplicaIdentity => SourceErrorDetails::Other(m),
433 DefiniteError::IncompatibleSchema(_) => SourceErrorDetails::Other(m),
434 DefiniteError::InvalidUTF8(_) => SourceErrorDetails::Other(m),
435 DefiniteError::CastError(_) => SourceErrorDetails::Other(m),
436 DefiniteError::UnexpectedBinaryData => SourceErrorDetails::Other(m),
437 },
438 }))
439 }
440}
441
442async fn ensure_replication_slot(client: &Client, slot: &str) -> Result<(), TransientError> {
443 let slot = Sql::ident(slot);
444 let query = sql!(
445 "CREATE_REPLICATION_SLOT {} LOGICAL \"pgoutput\" NOEXPORT_SNAPSHOT",
446 slot.clone()
447 );
448 match simple_query_opt(client, query).await {
449 Ok(_) => Ok(()),
450 Err(PostgresError::Postgres(err)) if err.code() == Some(&SqlState::DUPLICATE_OBJECT) => {
452 tracing::trace!(slot = %slot, "replication slot already existed");
453 Ok(())
454 }
455 Err(err) => Err(TransientError::PostgresError(err)),
456 }
457}
458
459struct SlotMetadata {
461 active_pid: Option<i32>,
464 confirmed_flush_lsn: MzOffset,
467}
468
469async fn fetch_slot_metadata(
471 client: &Client,
472 slot: &str,
473 interval: Duration,
474) -> Result<SlotMetadata, TransientError> {
475 loop {
476 let Some(row) = query_opt(
477 &**client,
478 sql!(
479 "SELECT active_pid, confirmed_flush_lsn \
480 FROM pg_replication_slots WHERE slot_name = $1"
481 ),
482 &[&slot],
483 )
484 .await?
485 else {
486 return Err(TransientError::MissingReplicationSlot);
487 };
488
489 match row.get::<_, Option<PgLsn>>("confirmed_flush_lsn") {
490 Some(lsn) => {
494 return Ok(SlotMetadata {
495 confirmed_flush_lsn: MzOffset::from(lsn),
496 active_pid: row.get("active_pid"),
497 });
498 }
499 None => tokio::time::sleep(interval).await,
503 };
504 }
505}
506
507fn verify_schema(
510 oid: u32,
511 info: &SourceOutputInfo,
512 upstream_info: &BTreeMap<u32, PostgresTableDesc>,
513) -> Result<(), DefiniteError> {
514 let current_desc = upstream_info.get(&oid).ok_or(DefiniteError::TableDropped)?;
515
516 let allow_oids_to_change_by_col_num = info
517 .desc
518 .columns
519 .iter()
520 .zip_eq(info.casts.iter())
521 .flat_map(|(col, (cast_type, _))| match cast_type {
522 CastType::Text => Some(col.col_num),
523 CastType::Natural => None,
524 })
525 .collect();
526
527 match info
528 .desc
529 .determine_compatibility(current_desc, &allow_oids_to_change_by_col_num)
530 {
531 Ok(()) => Ok(()),
532 Err(err) => Err(DefiniteError::IncompatibleSchema(err)),
533 }
534}
535
536fn cast_row(
538 casts: &[(CastType, StorageScalarExpr)],
539 datums: &[Datum<'_>],
540 row: &mut Row,
541) -> Result<(), DefiniteError> {
542 let arena = mz_repr::RowArena::new();
543 let mut packer = row.packer();
544 for (_, column_cast) in casts {
545 let datum = column_cast
546 .eval(datums, &arena)
547 .map_err(DefiniteError::CastError)?;
548 packer.push(datum);
549 }
550 Ok(())
551}
552
553fn decode_utf8_text(bytes: &[u8]) -> Result<Datum<'_>, DefiniteError> {
555 match std::str::from_utf8(bytes) {
556 Ok(text) => Ok(Datum::String(text)),
557 Err(_) => Err(DefiniteError::InvalidUTF8(bytes.to_vec())),
558 }
559}