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;
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 };
173 table_info
174 .entry(output.desc.oid)
175 .or_insert_with(BTreeMap::new)
176 .insert(idx, output);
177 }
178
179 let metrics = config.metrics.get_postgres_source_metrics(config.id);
180
181 let (snapshot_updates, rewinds, slot_ready, snapshot_err, snapshot_token) =
182 snapshot::render(
183 scope.clone(),
184 config.clone(),
185 self.clone(),
186 table_info.clone(),
187 metrics.snapshot_metrics.clone(),
188 );
189
190 let (repl_updates, probe_stream, repl_err, repl_token) = replication::render(
191 scope.clone(),
192 config.clone(),
193 self,
194 table_info,
195 rewinds,
196 slot_ready,
197 resume_uppers,
198 metrics,
199 );
200
201 let updates = snapshot_updates.concat(repl_updates);
202 let partition_count = u64::cast_from(config.source_exports.len());
203 let data_streams: Vec<_> = updates
204 .inner
205 .partition::<CapacityContainerBuilder<_>, _, _>(
206 partition_count,
207 |((output, data), time, diff): (
208 (usize, Result<SourceMessage, DataflowError>),
209 MzOffset,
210 Diff,
211 )| {
212 let output = u64::cast_from(output);
213 (output, (data, time, diff))
214 },
215 );
216 let mut data_collections = BTreeMap::new();
217 for (id, data_stream) in config.source_exports.keys().zip_eq(data_streams) {
218 data_collections.insert(*id, data_stream.as_collection());
219 }
220
221 let export_ids = config.source_exports.keys().copied();
222 let health_init = export_ids
223 .map(Some)
224 .chain(std::iter::once(None))
225 .map(|id| HealthStatusMessage {
226 id,
227 namespace: Self::STATUS_NAMESPACE,
228 update: HealthStatusUpdate::Running,
229 })
230 .collect::<Vec<_>>()
231 .to_stream(scope);
232
233 let errs = snapshot_err.concat(repl_err).map(move |err| {
237 let err_string = err.display_with_causes().to_string();
239 let hint = match &err {
240 ReplicationError::Definite(err) => err.hint(),
241 ReplicationError::Transient(_) => None,
242 };
243 let update = HealthStatusUpdate::halting(err_string.clone(), hint);
244
245 let namespace = match err {
246 ReplicationError::Transient(err)
247 if matches!(
248 &*err,
249 TransientError::PostgresError(PostgresError::Ssh(_))
250 | TransientError::PostgresError(PostgresError::SshIo(_))
251 ) =>
252 {
253 StatusNamespace::Ssh
254 }
255 _ => Self::STATUS_NAMESPACE,
256 };
257
258 HealthStatusMessage {
259 id: None,
260 namespace: namespace.clone(),
261 update,
262 }
263 });
264
265 let health = health_init.concat(errs);
266
267 (
268 data_collections,
269 health,
270 probe_stream,
271 vec![snapshot_token, repl_token],
272 )
273 }
274}
275
276#[derive(Clone, Debug)]
277struct SourceOutputInfo {
278 desc: PostgresTableDesc,
280 projection: Option<Vec<usize>>,
284 casts: Vec<(CastType, StorageScalarExpr)>,
285 resume_upper: Antichain<MzOffset>,
286 export_id: GlobalId,
287}
288
289#[derive(Clone, Debug, thiserror::Error)]
290pub enum ReplicationError {
291 #[error(transparent)]
292 Transient(#[from] Rc<TransientError>),
293 #[error(transparent)]
294 Definite(#[from] Rc<DefiniteError>),
295}
296
297#[derive(Debug, thiserror::Error)]
299pub enum TransientError {
300 #[error("replication slot mysteriously missing")]
301 MissingReplicationSlot,
302 #[error(
303 "slot overcompacted. Requested LSN {requested_lsn} but only LSNs >= {available_lsn} are available"
304 )]
305 OvercompactedReplicationSlot {
306 requested_lsn: MzOffset,
307 available_lsn: MzOffset,
308 },
309 #[error("replication slot already exists")]
310 ReplicationSlotAlreadyExists,
311 #[error("stream ended prematurely")]
312 ReplicationEOF,
313 #[error("unexpected replication message")]
314 UnknownReplicationMessage,
315 #[error("unexpected logical replication message")]
316 UnknownLogicalReplicationMessage,
317 #[error("received replication event outside of transaction")]
318 BareTransactionEvent,
319 #[error("lsn mismatch between BEGIN and COMMIT")]
320 InvalidTransaction,
321 #[error("BEGIN within existing BEGIN stream")]
322 NestedTransaction,
323 #[error("recoverable errors should crash the process during snapshots")]
324 SyntheticError,
325 #[error("sql client error")]
326 SQLClient(#[from] tokio_postgres::Error),
327 #[error(transparent)]
328 PostgresError(#[from] PostgresError),
329 #[error(transparent)]
330 Generic(#[from] anyhow::Error),
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
335pub enum DefiniteError {
336 #[error("slot compacted past snapshot point. snapshot consistent point={0} resume_lsn={1}")]
337 SlotCompactedPastResumePoint(MzOffset, MzOffset),
338 #[error("table was truncated")]
339 TableTruncated,
340 #[error("table was dropped")]
341 TableDropped,
342 #[error("publication {0:?} does not exist")]
343 PublicationDropped(String),
344 #[error("replication slot has been invalidated because it exceeded the maximum reserved size")]
345 InvalidReplicationSlot,
346 #[error("unexpected number of columns while parsing COPY output")]
347 MissingColumn,
348 #[error("failed to parse COPY protocol")]
349 InvalidCopyInput,
350 #[error(
351 "unsupported action: database restored from point-in-time backup. Expected timeline ID {expected} but got {actual}"
352 )]
353 InvalidTimelineId { expected: u64, actual: u64 },
354 #[error(
355 "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}"
356 )]
357 InvalidPhysicalReplica { expected: bool, actual: bool },
358 #[error(
359 "TOASTed value missing from old row. Did you forget to set REPLICA IDENTITY to FULL for your table?"
360 )]
361 MissingToast,
362 #[error(
363 "old row missing from replication stream. Did you forget to set REPLICA IDENTITY to FULL for your table?"
364 )]
365 DefaultReplicaIdentity,
366 #[error("{0}")]
367 IncompatibleSchema(SchemaChangeError),
368 #[error("invalid UTF8 string: {0:?}")]
369 InvalidUTF8(Vec<u8>),
370 #[error("failed to cast raw column: {0}")]
371 CastError(#[source] EvalError),
372 #[error("unexpected binary data in replication stream")]
373 UnexpectedBinaryData,
374}
375
376impl DefiniteError {
377 fn hint(&self) -> Option<String> {
378 match self {
379 DefiniteError::IncompatibleSchema(err) => err.hint(),
380 _ => None,
381 }
382 }
383}
384
385impl From<DefiniteError> for DataflowError {
386 fn from(err: DefiniteError) -> Self {
387 let m = err.to_string().into();
388 DataflowError::SourceError(Box::new(SourceError {
389 hint: err.hint().map(Into::into),
390 error: match &err {
391 DefiniteError::SlotCompactedPastResumePoint(_, _) => SourceErrorDetails::Other(m),
392 DefiniteError::TableTruncated => SourceErrorDetails::Other(m),
393 DefiniteError::TableDropped => SourceErrorDetails::Other(m),
394 DefiniteError::PublicationDropped(_) => SourceErrorDetails::Initialization(m),
395 DefiniteError::InvalidReplicationSlot => SourceErrorDetails::Initialization(m),
396 DefiniteError::MissingColumn => SourceErrorDetails::Other(m),
397 DefiniteError::InvalidCopyInput => SourceErrorDetails::Other(m),
398 DefiniteError::InvalidTimelineId { .. } => SourceErrorDetails::Initialization(m),
399 DefiniteError::InvalidPhysicalReplica { .. } => {
400 SourceErrorDetails::Initialization(m)
401 }
402 DefiniteError::MissingToast => SourceErrorDetails::Other(m),
403 DefiniteError::DefaultReplicaIdentity => SourceErrorDetails::Other(m),
404 DefiniteError::IncompatibleSchema(_) => SourceErrorDetails::Other(m),
405 DefiniteError::InvalidUTF8(_) => SourceErrorDetails::Other(m),
406 DefiniteError::CastError(_) => SourceErrorDetails::Other(m),
407 DefiniteError::UnexpectedBinaryData => SourceErrorDetails::Other(m),
408 },
409 }))
410 }
411}
412
413async fn ensure_replication_slot(client: &Client, slot: &str) -> Result<(), TransientError> {
414 let slot = Sql::ident(slot);
415 let query = sql!(
416 "CREATE_REPLICATION_SLOT {} LOGICAL \"pgoutput\" NOEXPORT_SNAPSHOT",
417 slot.clone()
418 );
419 match simple_query_opt(client, query).await {
420 Ok(_) => Ok(()),
421 Err(PostgresError::Postgres(err)) if err.code() == Some(&SqlState::DUPLICATE_OBJECT) => {
423 tracing::trace!(slot = %slot, "replication slot already existed");
424 Ok(())
425 }
426 Err(err) => Err(TransientError::PostgresError(err)),
427 }
428}
429
430struct SlotMetadata {
432 active_pid: Option<i32>,
435 confirmed_flush_lsn: MzOffset,
438}
439
440async fn fetch_slot_metadata(
442 client: &Client,
443 slot: &str,
444 interval: Duration,
445) -> Result<SlotMetadata, TransientError> {
446 loop {
447 let Some(row) = query_opt(
448 &**client,
449 sql!(
450 "SELECT active_pid, confirmed_flush_lsn \
451 FROM pg_replication_slots WHERE slot_name = $1"
452 ),
453 &[&slot],
454 )
455 .await?
456 else {
457 return Err(TransientError::MissingReplicationSlot);
458 };
459
460 match row.get::<_, Option<PgLsn>>("confirmed_flush_lsn") {
461 Some(lsn) => {
465 return Ok(SlotMetadata {
466 confirmed_flush_lsn: MzOffset::from(lsn),
467 active_pid: row.get("active_pid"),
468 });
469 }
470 None => tokio::time::sleep(interval).await,
474 };
475 }
476}
477
478fn verify_schema(
481 oid: u32,
482 info: &SourceOutputInfo,
483 upstream_info: &BTreeMap<u32, PostgresTableDesc>,
484) -> Result<(), DefiniteError> {
485 let current_desc = upstream_info.get(&oid).ok_or(DefiniteError::TableDropped)?;
486
487 let allow_oids_to_change_by_col_num = info
488 .desc
489 .columns
490 .iter()
491 .zip_eq(info.casts.iter())
492 .flat_map(|(col, (cast_type, _))| match cast_type {
493 CastType::Text => Some(col.col_num),
494 CastType::Natural => None,
495 })
496 .collect();
497
498 match info
499 .desc
500 .determine_compatibility(current_desc, &allow_oids_to_change_by_col_num)
501 {
502 Ok(()) => Ok(()),
503 Err(err) => Err(DefiniteError::IncompatibleSchema(err)),
504 }
505}
506
507fn cast_row(
509 casts: &[(CastType, StorageScalarExpr)],
510 datums: &[Datum<'_>],
511 row: &mut Row,
512) -> Result<(), DefiniteError> {
513 let arena = mz_repr::RowArena::new();
514 let mut packer = row.packer();
515 for (_, column_cast) in casts {
516 let datum = column_cast
517 .eval(datums, &arena)
518 .map_err(DefiniteError::CastError)?;
519 packer.push(datum);
520 }
521 Ok(())
522}
523
524fn decode_utf8_text(bytes: &[u8]) -> Result<Datum<'_>, DefiniteError> {
526 match std::str::from_utf8(bytes) {
527 Ok(text) => Ok(Datum::String(text)),
528 Err(_) => Err(DefiniteError::InvalidUTF8(bytes.to_vec())),
529 }
530}