Skip to main content

mz_storage/source/
postgres.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//! Code to render the ingestion dataflow of a [`PostgresSourceConnection`]. The dataflow consists
11//! of multiple operators in order to take advantage of all the available workers.
12//!
13//! # Snapshot
14//!
15//! One part of the dataflow deals with snapshotting the tables involved in the ingestion. Each
16//! table is partitioned across all workers using PostgreSQL's `ctid` column to identify row
17//! ranges. Each worker fetches its assigned range using a `COPY` query with ctid filtering,
18//! enabling parallel snapshotting of large tables.
19//!
20//! For all tables that ended up being snapshotted the snapshot reader also emits a rewind request
21//! to the replication reader which will ensure that the requested portion of the replication
22//! stream is subtracted from the snapshot.
23//!
24//! See the [snapshot] module for more information on the snapshot strategy.
25//!
26//! # Replication
27//!
28//! The other part of the dataflow deals with reading the logical replication slot, which must
29//! happen from a single worker. The minimum amount of processing is performed from that worker
30//! and the data is then distributed among all workers for decoding.
31//!
32//! See the [replication] module for more information on the replication strategy.
33//!
34//! # Error handling
35//!
36//! There are two kinds of errors that can happen during ingestion that are represented as two
37//! separate error types:
38//!
39//! [`DefiniteError`]s are errors that happen during processing of a specific
40//! collection record at a specific LSN. These are the only errors that can ever end up in the
41//! error collection of a subsource.
42//!
43//! Transient errors are any errors that can happen for reasons that are unrelated to the data
44//! itself. This could be authentication failures, connection failures, etc. The only operators
45//! that can emit such errors are the `TableReader` and the `ReplicationReader` operators, which
46//! are the ones that talk to the external world. Both of these operators are built with the
47//! `AsyncOperatorBuilder::build_fallible` method which allows transient errors to be propagated
48//! upwards with the standard `?` operator without risking downgrading the capability and producing
49//! bogus frontiers.
50//!
51//! The error streams from both of those operators are published to the source status and also
52//! trigger a restart of the dataflow.
53//!
54//! ```text
55//!    ┏━━━━━━━━━━━━━━┓
56//!    ┃    table     ┃
57//!    ┃    reader    ┃
58//!    ┗━┯━━━━━━━━━━┯━┛
59//!      │          │rewind
60//!      │          │requests
61//!      │          ╰────╮
62//!      │             ┏━v━━━━━━━━━━━┓
63//!      │             ┃ replication ┃
64//!      │             ┃   reader    ┃
65//!      │             ┗━┯━━━━━━━━━┯━┛
66//!  COPY│           slot│         │
67//!  data│           data│         │
68//! ┏━━━━v━━━━━┓ ┏━━━━━━━v━━━━━┓   │
69//! ┃  COPY    ┃ ┃ replication ┃   │
70//! ┃ decoder  ┃ ┃   decoder   ┃   │
71//! ┗━━━━┯━━━━━┛ ┗━━━━━┯━━━━━━━┛   │
72//!      │snapshot     │replication│
73//!      │updates      │updates    │
74//!      ╰────╮    ╭───╯           │
75//!          ╭┴────┴╮              │
76//!          │concat│              │
77//!          ╰──┬───╯              │
78//!             │ data             │progress
79//!             │ output           │output
80//!             v                  v
81//! ```
82
83use 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    /// Render the ingestion dataflow. This function only connects things together and contains no
126    /// actual processing logic.
127    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        // Collect the source outputs that we will be exporting into a per-table map.
143        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                // This is an export that doesn't need any data output to it.
153                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        // N.B. Note that we don't check ssh tunnel statuses here. We could, but immediately on
234        // restart we are going to set the status to an ssh error correctly, so we don't do this
235        // extra work.
236        let errs = snapshot_err.concat(repl_err).map(move |err| {
237            // This update will cause the dataflow to restart
238            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    /// The expected upstream schema of this output.
279    desc: PostgresTableDesc,
280    /// A projection of the upstream columns into the columns expected by this output. This field
281    /// is recalculated every time we observe an upstream schema change. On dataflow initialization
282    /// this field is None since we haven't yet observed any schemas.
283    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/// A transient error that never ends up in the collection of a specific table.
298#[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/// A definite error that always ends up in the collection of a specific table.
334#[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        // If the slot already exists that's still ok
422        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
430/// The state of a replication slot.
431struct SlotMetadata {
432    /// The process ID of the session using this slot if the slot is currently actively being used.
433    /// None if inactive.
434    active_pid: Option<i32>,
435    /// The address (LSN) up to which the logical slot's consumer has confirmed receiving data.
436    /// Data corresponding to the transactions committed before this LSN is not available anymore.
437    confirmed_flush_lsn: MzOffset,
438}
439
440/// Fetches the minimum LSN at which this slot can safely resume.
441async 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            // For postgres, `confirmed_flush_lsn` means that the slot is able to produce
462            // all transactions that happen at tx_lsn >= confirmed_flush_lsn. Therefore this value
463            // already has "upper" semantics.
464            Some(lsn) => {
465                return Ok(SlotMetadata {
466                    confirmed_flush_lsn: MzOffset::from(lsn),
467                    active_pid: row.get("active_pid"),
468                });
469            }
470            // It can happen that confirmed_flush_lsn is NULL as the slot initializes
471            // This could probably be a `tokio::time::interval`, but its only is called twice,
472            // so its fine like this.
473            None => tokio::time::sleep(interval).await,
474        };
475    }
476}
477
478// Ensures that the table with oid `oid` and expected schema `expected_schema` is still compatible
479// with the current upstream schema `upstream_info`.
480fn 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
507/// Casts a text row into the target types
508fn 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
524/// Converts raw bytes that are expected to be UTF8 encoded into a `Datum::String`
525fn 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}