Skip to main content

mz_storage/source/postgres/
snapshot.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 table snapshot side of the [`PostgresSourceConnection`] ingestion dataflow.
11//!
12//! # Snapshot reading
13//!
14//! Depending on the resumption LSNs the table reader decides which tables need to be snapshotted.
15//! Each table is partitioned across all workers using PostgreSQL's `ctid` (tuple identifier)
16//! column, which identifies the physical location of each row. This allows parallel snapshotting
17//! of large tables across all available workers.
18//!
19//! There are a few subtle points about this operation, described in the following sections.
20//!
21//! ## Consistent LSN point for snapshot transactions
22//!
23//! Given that all our ingestion is based on correctly timestamping updates with the LSN they
24//! happened at it is important that we run the `COPY` query at a specific LSN point that is
25//! relatable with the LSN numbers we receive from the replication stream. Such point does not
26//! necessarily exist for a normal SQL transaction. To achieve this we must force postgres to
27//! produce a consistent point and let us know of the LSN number of that by creating a replication
28//! slot as the first statement in a transaction.
29//!
30//! This is a temporary dummy slot that is only used to put our snapshot transaction on a
31//! consistent LSN point. Unfortunately no lighterweight method exists for doing this. See this
32//! [postgres thread] for more details.
33//!
34//! One might wonder why we don't use the actual real slot to provide us with the snapshot point
35//! which would automatically be at the correct LSN. The answer is that it's possible that we crash
36//! and restart after having already created the slot but before having finished the snapshot. In
37//! that case the restarting process will have lost its opportunity to run queries at the slot's
38//! consistent point as that opportunity only exists in the ephemeral transaction that created the
39//! slot and that is long gone. Additionally there are good reasons of why we'd like to move the
40//! slot creation much earlier, e.g during purification, in which case the slot will always be
41//! pre-created.
42//!
43//! [postgres thread]: https://www.postgresql.org/message-id/flat/CAMN0T-vzzNy6TV1Jvh4xzNQdAvCLBQK_kh6_U7kAXgGU3ZFg-Q%40mail.gmail.com
44//!
45//! ## Reusing the consistent point among all workers
46//!
47//! Creating replication slots is potentially expensive so the code makes is such that all workers
48//! cooperate and reuse one consistent snapshot among them. In order to do so we make use the
49//! "export transaction" feature of postgres. This feature allows one SQL session to create an
50//! identifier for the transaction (a string identifier) it is currently in, which can be used by
51//! other sessions to enter the same "snapshot".
52//!
53//! We accomplish this by picking one worker at random to function as the transaction leader. The
54//! transaction leader is responsible for starting a SQL session, creating a temporary replication
55//! slot in a transaction, exporting the transaction id, and broadcasting the transaction
56//! information to all other workers via a broadcasted feedback edge.
57//!
58//! During this phase the follower workers are simply waiting to hear on the feedback edge,
59//! effectively synchronizing with the leader. Once all workers have received the snapshot
60//! information they can all start to perform their assigned COPY queries.
61//!
62//! The leader and follower steps described above are accomplished by the [`export_snapshot`] and
63//! [`use_snapshot`] functions respectively.
64//!
65//! ## Coordinated transaction COMMIT
66//!
67//! When follower workers are done with snapshotting they commit their transaction, close their
68//! session, and then drop their snapshot feedback capability. When the leader worker is done with
69//! snapshotting it drops its snapshot feedback capability and waits until it observes the
70//! snapshot input advancing to the empty frontier. This allows the leader to COMMIT its
71//! transaction last, which is the transaction that exported the snapshot.
72//!
73//! It's unclear if this is strictly necessary, but having the frontiers made it easy enough that I
74//! added the synchronization.
75//!
76//! ## Snapshot rewinding
77//!
78//! Ingestion dataflows must produce definite data, including the snapshot. What this means
79//! practically is that whenever we deem it necessary to snapshot a table we must do so at the same
80//! LSN. However, the method for running a transaction described above doesn't let us choose the
81//! LSN, it could be an LSN in the future chosen by PostgresSQL while it creates the temporary
82//! replication slot.
83//!
84//! The definition of differential collections states that a collection at some time `t_snapshot`
85//! is defined to be the accumulation of all updates that happen at `t <= t_snapshot`, where `<=`
86//! is the partial order. In this case we are faced with the problem of knowing the state of a
87//! table at `t_snapshot` but actually wanting to know the snapshot at `t_slot <= t_snapshot`.
88//!
89//! From the definition we can see that the snapshot at `t_slot` is related to the snapshot at
90//! `t_snapshot` with the following equations:
91//!
92//!```text
93//! sum(update: t <= t_snapshot) = sum(update: t <= t_slot) + sum(update: t_slot <= t <= t_snapshot)
94//!                                         |
95//!                                         V
96//! sum(update: t <= t_slot) = sum(update: t <= snapshot) - sum(update: t_slot <= t <= t_snapshot)
97//! ```
98//!
99//! Therefore, if we manage to recover the `sum(update: t_slot <= t <= t_snapshot)` term we will be
100//! able to "rewind" the snapshot we obtained at `t_snapshot` to `t_slot` by emitting all updates
101//! that happen between these two points with their diffs negated.
102//!
103//! It turns out that this term is exactly what the main replication slot provides us with and we
104//! can rewind snapshot at arbitrary points! In order to do this the snapshot dataflow emits rewind
105//! requests to the replication reader which informs it that a certain range of updates must be
106//! emitted at LSN 0 (by convention) with their diffs negated. These negated diffs are consolidated
107//! with the diffs taken at `t_snapshot` that were also emitted at LSN 0 (by convention) and we end
108//! up with a TVC that at LSN 0 contains the snapshot at `t_slot`.
109//!
110//! # Parallel table snapshotting with ctid ranges
111//!
112//! Each table is partitioned across workers using PostgreSQL's `ctid` column. The `ctid` is a
113//! tuple identifier of the form `(block_number, tuple_index)` that represents the physical
114//! location of a row on disk. By partitioning the ctid range, each worker can independently
115//! fetch a portion of the table.
116//!
117//! The partitioning works as follows:
118//! 1. The snapshot leader queries `pg_class.relpages` to estimate the number of blocks for each
119//!    table. This is much faster than querying `max(ctid)` which would require a sequential scan.
120//! 2. The leader broadcasts the block count estimates along with the snapshot transaction ID
121//!    to all workers, ensuring all workers use consistent estimates for partitioning.
122//! 3. Each worker calculates its assigned block range and fetches rows using a `COPY` query
123//!    with a `SELECT` that filters by `ctid >= start AND ctid < end`.
124//! 4. The last worker uses an open-ended range (`ctid >= start`) to capture any rows beyond
125//!    the estimated block count (handles cases where statistics are stale or table has grown).
126//!
127//! This approach efficiently parallelizes large table snapshots while maintaining the benefits
128//! of the `COPY` protocol for bulk data transfer.
129//!
130//! ## PostgreSQL version requirements
131//!
132//! Ctid range scans are only efficient on PostgreSQL >= 14 due to TID range scan optimizations
133//! introduced in that version. For older PostgreSQL versions, the snapshot falls back to the
134//! single-worker-per-table mode where each table is assigned to one worker based on consistent
135//! hashing. This is implemented by having the leader broadcast all-zero block counts when
136//! PostgreSQL version < 14.
137//!
138//! # Snapshot decoding
139//!
140//! Each worker fetches its ctid range directly and decodes the COPY stream locally.
141//!
142//! ```text
143//!                 ╭──────────────────╮
144//!    ┏━━━━━━━━━━━━v━┓                │ exported
145//!    ┃    table     ┃   ╭─────────╮  │ snapshot id
146//!    ┃   readers    ┠─>─┤broadcast├──╯
147//!    ┃  (parallel)  ┃   ╰─────────╯
148//!    ┗━┯━━━━━━━━━━┯━┛
149//!   raw│          │
150//!  COPY│          │
151//!  data│          │
152//! ┏━━━━┷━━━━┓     │
153//! ┃  COPY   ┃     │
154//! ┃ decoder ┃     │
155//! ┗━━━━┯━━━━┛     │
156//!      │ snapshot │rewind
157//!      │ updates  │requests
158//!      v          v
159//! ```
160
161use std::collections::BTreeMap;
162use std::convert::Infallible;
163use std::pin::pin;
164use std::rc::Rc;
165use std::sync::Arc;
166use std::time::Duration;
167
168use anyhow::bail;
169use bytes::Bytes;
170use differential_dataflow::AsCollection;
171use futures::{StreamExt as _, TryStreamExt};
172use mz_ore::cast::CastFrom;
173use mz_ore::future::InTask;
174use mz_postgres_util::desc::PostgresTableDesc;
175use mz_postgres_util::schemas::get_pg_major_version;
176use mz_postgres_util::{Client, Config, PostgresError, Sql, simple_query, simple_query_opt, sql};
177use mz_repr::{Datum, DatumVec, Diff, Row};
178use mz_storage_types::connections::ConnectionContext;
179use mz_storage_types::dyncfgs::{
180    PG_FETCH_SLOT_RESUME_LSN_INTERVAL, PG_SOURCE_REPLICATION_SLOT_CREATION_TIMEOUT,
181    STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD,
182};
183use mz_storage_types::errors::DataflowError;
184use mz_storage_types::parameters::PgSourceSnapshotConfig;
185use mz_storage_types::sources::{MzOffset, PostgresSourceConnection};
186use mz_timely_util::builder_async::{
187    Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
188};
189use timely::container::CapacityContainerBuilder;
190use timely::dataflow::channels::pact::Pipeline;
191use timely::dataflow::operators::core::Map;
192use timely::dataflow::operators::vec::Broadcast;
193use timely::dataflow::operators::{
194    Capability, CapabilitySet, Concat, ConnectLoop, Feedback, Operator,
195};
196use timely::dataflow::{Scope, StreamVec};
197use timely::progress::Timestamp;
198use tokio_postgres::types::{Oid, PgLsn};
199use tracing::trace;
200
201use crate::metrics::source::postgres::PgSnapshotMetrics;
202use crate::source::RawSourceCreationConfig;
203use crate::source::postgres::replication::{
204    RewindRequest, SnapshotResult, ensure_replication_prerequisites,
205};
206use crate::source::postgres::{
207    DefiniteError, ReplicationError, SourceOutputInfo, TransientError, verify_schema,
208};
209use crate::source::types::{SharedFuel, SignaledFuture, SourceMessage, StackedCollection};
210use crate::statistics::SourceStatistics;
211
212/// Information broadcasted from the snapshot leader to all workers.
213/// This includes the transaction snapshot ID, LSN, and estimated block counts for each table.
214#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
215struct SnapshotInfo {
216    /// The exported transaction snapshot identifier.
217    snapshot_id: String,
218    /// The LSN at which the snapshot was taken.
219    snapshot_lsn: MzOffset,
220    /// Estimated number of blocks (pages) for each table, keyed by OID.
221    /// This is derived from `pg_class.relpages` and used to partition ctid ranges.
222    table_block_counts: BTreeMap<u32, u64>,
223    /// The current upstream schema of each table.
224    upstream_info: BTreeMap<u32, PostgresTableDesc>,
225}
226
227/// Represents a ctid range that a worker should snapshot.
228/// The range is [start_block, end_block) where end_block is optional (None means unbounded).
229#[derive(Debug)]
230struct CtidRange {
231    /// The starting block number (inclusive).
232    start_block: u64,
233    /// The ending block number (exclusive). None means unbounded (open-ended range).
234    end_block: Option<u64>,
235}
236
237/// Calculate the ctid range for a given worker based on estimated block count.
238///
239/// The table is partitioned by block number across all workers. Each worker gets a contiguous
240/// range of blocks. The last worker gets an open-ended range to handle any rows beyond the
241/// estimated block count.
242///
243/// When `estimated_blocks` is 0 (either because statistics are unavailable, the table appears
244/// empty, or PostgreSQL version < 14 doesn't support ctid range scans), the table is assigned
245/// to a single worker determined by `config.responsible_for(oid)` and that worker scans the
246/// full table.
247///
248/// Returns None if this worker has no work to do.
249fn worker_ctid_range(
250    config: &RawSourceCreationConfig,
251    estimated_blocks: u64,
252    oid: u32,
253) -> Option<CtidRange> {
254    // If estimated_blocks is 0, fall back to single-worker mode for this table.
255    // This handles:
256    // - PostgreSQL < 14 (ctid range scans not supported)
257    // - Tables that appear empty in statistics
258    // - Tables with stale/missing statistics
259    // The responsible worker scans the full table with an open-ended range.
260    if estimated_blocks == 0 {
261        let fallback = if config.responsible_for(oid) {
262            Some(CtidRange {
263                start_block: 0,
264                end_block: None,
265            })
266        } else {
267            None
268        };
269        return fallback;
270    }
271
272    let worker_id = u64::cast_from(config.worker_id);
273    let worker_count = u64::cast_from(config.worker_count);
274
275    // If there are more workers than blocks, only assign work to workers with id < estimated_blocks
276    // The last assigned worker still gets an open range.
277    let effective_worker_count = std::cmp::min(worker_count, estimated_blocks);
278
279    if worker_id >= effective_worker_count {
280        // This worker has no work to do
281        return None;
282    }
283
284    // Calculate start block for this worker (integer division distributes blocks evenly)
285    let start_block = worker_id * estimated_blocks / effective_worker_count;
286
287    // The last effective worker gets an open-ended range
288    let is_last_effective_worker = worker_id == effective_worker_count - 1;
289    if is_last_effective_worker {
290        Some(CtidRange {
291            start_block,
292            end_block: None,
293        })
294    } else {
295        let end_block = (worker_id + 1) * estimated_blocks / effective_worker_count;
296        Some(CtidRange {
297            start_block,
298            end_block: Some(end_block),
299        })
300    }
301}
302
303/// Estimate the number of blocks for each table from pg_class statistics.
304/// This is used to partition ctid ranges across workers.
305async fn estimate_table_block_counts(
306    client: &Client,
307    table_oids: &[u32],
308) -> Result<BTreeMap<u32, u64>, TransientError> {
309    if table_oids.is_empty() {
310        return Ok(BTreeMap::new());
311    }
312
313    // Query relpages for all tables at once.
314    let oid_list = Sql::join(table_oids.iter().copied().map(Sql::from), ",");
315    let query = sql!(
316        "SELECT oid, relpages FROM pg_class WHERE oid IN ({})",
317        oid_list
318    );
319
320    let mut block_counts = BTreeMap::new();
321    // Initialize all tables with 0 blocks (in case they're not in pg_class)
322    for &oid in table_oids {
323        block_counts.insert(oid, 0);
324    }
325
326    // Execute the query and collect results
327    let rows = simple_query(client, query).await?;
328    for msg in rows {
329        if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
330            let oid: u32 = row.get("oid").unwrap().parse().unwrap();
331            let relpages: i64 = row.get("relpages").unwrap().parse().unwrap_or(0);
332            // relpages can be -1 if never analyzed, treat as 0
333            let relpages = std::cmp::max(0, relpages).try_into().unwrap();
334            block_counts.insert(oid, relpages);
335        }
336    }
337
338    Ok(block_counts)
339}
340
341/// Renders the snapshot dataflow. See the module documentation for more information.
342pub(crate) fn render<'scope>(
343    scope: Scope<'scope, MzOffset>,
344    config: RawSourceCreationConfig,
345    connection: PostgresSourceConnection,
346    table_info: BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
347    metrics: PgSnapshotMetrics,
348) -> (
349    Vec<StackedCollection<'scope, MzOffset, Result<SourceMessage, DataflowError>>>,
350    StreamVec<'scope, MzOffset, SnapshotResult>,
351    StreamVec<'scope, MzOffset, Infallible>,
352    StreamVec<'scope, MzOffset, ReplicationError>,
353    PressOnDropButton,
354) {
355    let op_name = format!("TableReader({})", config.id);
356    let mut builder = AsyncOperatorBuilder::new(op_name, scope.clone());
357
358    let (feedback_handle, feedback_data) = scope.feedback(Default::default());
359
360    // One data output port per source export, in output index order. With concurrent
361    // replication enabled each port is held at the minimum only while this operator still has
362    // snapshot data to emit for that export. When disabled all ports are held until the
363    // entire snapshot completes, so every export observes the same frontier.
364    let export_count = config.source_exports.len();
365    let mut raw_handles = Vec::with_capacity(export_count);
366    let mut raw_streams = Vec::with_capacity(export_count);
367    for _ in 0..export_count {
368        let (handle, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
369        raw_handles.push(handle);
370        raw_streams.push(stream);
371    }
372    let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<_>>();
373    // This output is used to signal to the replication operator that the replication slot has been
374    // created. With the current state of execution serialization there isn't a lot of benefit
375    // of splitting the snapshot and replication phases into two operators.
376    // TODO(petrosagg): merge the two operators in one (while still maintaining separation as
377    // functions/modules)
378    let (_, slot_ready) = builder.new_output::<CapacityContainerBuilder<_>>();
379    let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<_>>();
380    let (definite_error_handle, definite_errors) =
381        builder.new_output::<CapacityContainerBuilder<_>>();
382
383    // This operator needs to broadcast data to itself in order to synchronize the transaction
384    // snapshot. However, none of the feedback capabilities result in output messages and for the
385    // feedback edge specifically having a default conncetion would result in a loop.
386    let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
387
388    // The export id must be sent to all workers, so we broadcast the feedback connection
389    snapshot.broadcast().connect_loop(feedback_handle);
390
391    let is_snapshot_leader = config.responsible_for("snapshot_leader");
392
393    // A global view of all outputs that will be snapshot by all workers.
394    let mut all_outputs = vec![];
395    // Table info for tables that need snapshotting. All workers will snapshot all tables,
396    // but each worker will handle a different ctid range within each table.
397    let mut tables_to_snapshot = BTreeMap::new();
398    // A collection of `SourceStatistics` to update for a given Oid. Same info exists in table_info,
399    // but this avoids having to iterate + map each time the statistics are needed.
400    let mut export_statistics = BTreeMap::new();
401    for (table, outputs) in table_info.iter() {
402        for (&output_index, output) in outputs {
403            if *output.resume_upper != [MzOffset::minimum()] {
404                // Already has been snapshotted.
405                continue;
406            }
407            all_outputs.push(output_index);
408            tables_to_snapshot
409                .entry(*table)
410                .or_insert_with(BTreeMap::new)
411                .insert(output_index, output.clone());
412            let statistics = config
413                .statistics
414                .get(&output.export_id)
415                .expect("statistics are initialized")
416                .clone();
417            export_statistics.insert((*table, output_index), statistics);
418        }
419    }
420
421    // Read at render time, matching the value the persist sink reads in the same dataflow build.
422    // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's
423    // creation and disagree with the sink because the `ConfigSet` is updated in place.
424    // A nonzero lookahead is the switch, see `STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD`.
425    let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD
426        .get(config.config.config_set())
427        .is_zero();
428
429    let (button, transient_errors) = builder.build_fallible(move |caps| {
430        let busy_signal = Arc::clone(&config.busy_signal);
431        Box::pin(SignaledFuture::new(busy_signal, async move {
432            let id = config.id;
433            let worker_id = config.worker_id;
434            let (data_cap_sets, caps) = caps.split_at_mut(export_count);
435            let [
436                rewind_cap_set,
437                slot_ready_cap_set,
438                snapshot_cap_set,
439                definite_error_cap_set,
440            ]: &mut [_; 4] = caps.try_into().unwrap();
441            let mut raw_handles = SharedFuel::new(raw_handles);
442
443            // This operator only ever emits snapshot data for the exports it snapshots. With
444            // concurrent replication the ports of all other exports are closed up front, so
445            // their frontiers are determined by the replication operator alone and they make
446            // progress while the snapshot runs. Ports of snapshotted exports close one by one
447            // as each export's copy finishes on this worker.
448            if concurrent_replication {
449                for (output_index, cap_set) in data_cap_sets.iter_mut().enumerate() {
450                    if !all_outputs.contains(&output_index) {
451                        *cap_set = CapabilitySet::new();
452                    }
453                }
454            }
455
456            let connection_config = connection
457                .connection
458                .config(
459                    &config.config.connection_context.secrets_reader,
460                    &config.config,
461                    InTask::Yes,
462                )
463                .await?;
464
465            let slot_creation_timeout =
466                PG_SOURCE_REPLICATION_SLOT_CREATION_TIMEOUT.get(config.config.config_set());
467
468            // The snapshot operator is responsible for creating the replication slot(s).
469            // This first slot is the permanent slot that will be used for reading the replication
470            // stream.  A temporary slot is created further on to capture table snapshots.
471            let replication_client = if is_snapshot_leader {
472                tracing::info!(
473                    %id,
474                    "timely-{worker_id} (leader) initializing table reader \
475                        with {} tables to snapshot",
476                    tables_to_snapshot.len()
477                );
478                let client = connection_config
479                    .connect_replication(&config.config.connection_context.ssh_tunnel_manager)
480                    .await?;
481                let main_slot = &connection.publication_details.slot;
482
483                tracing::info!(%id, "ensuring replication slot {main_slot} exists");
484                super::ensure_replication_slot(
485                    &client,
486                    main_slot,
487                    connection.failover,
488                    slot_creation_timeout,
489                )
490                .await?;
491                Some(client)
492            } else {
493                None
494            };
495            *slot_ready_cap_set = CapabilitySet::new();
496
497            // Nothing needs to be snapshot.
498            if all_outputs.is_empty() {
499                trace!(%id, "no exports to snapshot");
500                // Note we do not emit a `ProgressStatisticsUpdate::Snapshot` update here,
501                // as we do not want to attempt to override the current value with 0. We
502                // just leave it null.
503                return Ok(());
504            }
505
506            // A worker *must* emit a count even if not responsible for snapshotting a table
507            // as statistic summarization will return null if any worker hasn't set a value.
508            // This will also reset snapshot stats for any exports not snapshotting.
509            // If no workers need to snapshot, then avoid emitting these as they will clear
510            // previous stats.
511            for statistics in config.statistics.values() {
512                statistics.set_snapshot_records_known(0);
513                statistics.set_snapshot_records_staged(0);
514            }
515
516            // Collect table OIDs for block count estimation
517            let table_oids: Vec<u32> = tables_to_snapshot.keys().copied().collect();
518
519            // replication client is only set if this worker is the snapshot leader
520            let client = match replication_client {
521                Some(client) => 'leader: {
522                    // The replication operator detects the same conditions before it reads the
523                    // stream. With concurrent replication that happens during the copy, and a
524                    // definite error found there cannot commit while this operator holds the
525                    // exports at the minimum. The dataflow then restarts and repeats the copy for
526                    // as long as the condition holds. Checking here first fails before any copy.
527                    //
528                    // Definite errors are broadcast in place of the snapshot info so that every
529                    // worker returns and releases its capabilities. A worker left waiting for the
530                    // snapshot info holds the exports at the minimum.
531                    let metadata_client = connection_config
532                        .connect(
533                            "snapshot metadata",
534                            &config.config.connection_context.ssh_tunnel_manager,
535                        )
536                        .await?;
537                    let slot_metadata = super::fetch_slot_metadata(
538                        &metadata_client,
539                        &connection.publication_details.slot,
540                        PG_FETCH_SLOT_RESUME_LSN_INTERVAL.get(config.config.config_set()),
541                    )
542                    .await?;
543                    let prerequisites = if slot_metadata.invalidated {
544                        Err(DefiniteError::InvalidReplicationSlot)
545                    } else {
546                        ensure_replication_prerequisites(
547                            &metadata_client,
548                            &client,
549                            &connection,
550                            config.config.config_set(),
551                        )
552                        .await?
553                    };
554                    if let Err(err) = prerequisites {
555                        snapshot_handle.give(&snapshot_cap_set[0], Err(err));
556                        break 'leader client;
557                    }
558
559                    let tmp_slot = format!("mzsnapshot_{}", uuid::Uuid::new_v4()).replace('-', "");
560                    tracing::info!(
561                        %id,
562                        "timely-{worker_id} (leader) creating temporary replication slot {tmp_slot}"
563                    );
564                    let (snapshot_id, snapshot_lsn) =
565                        export_snapshot(&client, &tmp_slot, true, slot_creation_timeout).await?;
566                    tracing::info!(
567                        %id,
568                        "timely-{worker_id} (leader) exported snapshot {snapshot_id} \
569                            @ {snapshot_lsn}"
570                    );
571
572                    // Check PostgreSQL version. Ctid range scans are only efficient on PG >= 14
573                    // due to improvements in TID range scan support.
574                    let pg_version = get_pg_major_version(&client).await?;
575
576                    // Estimate block counts for all tables from pg_class statistics.
577                    // This must be done by the leader and broadcasted to ensure all workers
578                    // use the same estimates for ctid range partitioning.
579                    //
580                    // For PostgreSQL < 14, we set all block counts to 0 to fall back to
581                    // single-worker-per-table mode, as ctid range scans are not well supported.
582                    let table_block_counts = if pg_version >= 14 {
583                        estimate_table_block_counts(&client, &table_oids).await?
584                    } else {
585                        trace!(
586                            %id,
587                            "timely-{worker_id} PostgreSQL version {pg_version} < 14, \
588                             falling back to single-worker-per-table snapshot mode"
589                        );
590                        // Return all zeros to trigger fallback mode
591                        table_oids.iter().map(|&oid| (oid, 0u64)).collect()
592                    };
593
594                    report_snapshot_size(
595                        &client,
596                        &tables_to_snapshot,
597                        metrics,
598                        &config,
599                        &export_statistics,
600                    )
601                    .await?;
602
603                    let upstream_info = {
604                        // As part of retrieving the schema info, RLS policies are checked to ensure the
605                        // snapshot can successfully read the tables. RLS policy errors are treated as
606                        // transient, as the customer can simply add the BYPASSRLS to the PG account
607                        // used by MZ.
608                        match retrieve_schema_info(
609                            &connection_config,
610                            &config.config.connection_context,
611                            &connection.publication,
612                            &table_oids)
613                            .await
614                        {
615                            // If the replication stream cannot be obtained in a definite way there is
616                            // nothing else to do. These errors are not retractable.
617                            Err(PostgresError::PublicationMissing(publication)) => {
618                                let err = DefiniteError::PublicationDropped(publication);
619                                snapshot_handle.give(&snapshot_cap_set[0], Err(err));
620                                break 'leader client;
621                            },
622                            Err(e) => Err(TransientError::from(e))?,
623                            Ok(i) => i,
624                        }
625                    };
626
627                    let snapshot_info = SnapshotInfo {
628                        snapshot_id,
629                        snapshot_lsn,
630                        upstream_info,
631                        table_block_counts,
632                    };
633                    trace!(
634                        %id,
635                        "timely-{worker_id} exporting snapshot info {snapshot_info:?}");
636                    snapshot_handle.give(&snapshot_cap_set[0], Ok(snapshot_info));
637
638                    client
639                }
640                None => {
641                    // Only the snapshot leader needs a replication connection.
642                    let task_name = format!("timely-{worker_id} PG snapshotter");
643                    connection_config
644                        .connect(
645                            &task_name,
646                            &config.config.connection_context.ssh_tunnel_manager,
647                        )
648                        .await?
649                }
650            };
651
652            // Configure statement_timeout based on param. We want to be able to
653            // override the server value here in case it's set too low,
654            // respective to the size of the data we need to copy.
655            super::set_timeout(
656                &client,
657                "statement_timeout",
658                config
659                    .config
660                    .parameters
661                    .pg_source_snapshot_statement_timeout,
662            )
663            .await?;
664
665            let snapshot_info = loop {
666                match snapshot_input.next().await {
667                    Some(AsyncEvent::Data(_, mut data)) => {
668                        break data.pop().expect("snapshot sent above")
669                    }
670                    Some(AsyncEvent::Progress(_)) => continue,
671                    None => panic!(
672                        "feedback closed \
673                    before sending snapshot info"
674                    ),
675                }
676            };
677
678            // The snapshot transaction starts after every output's schema was captured during
679            // purification, so no output's initial LSN can exceed the snapshot LSN. A violation
680            // means the upstream went back in time, which would leave the rewind range the
681            // replication operator subtracts unable to reach the snapshot.
682            let snapshot_info = snapshot_info.and_then(|info| {
683                let invalid_lsn = tables_to_snapshot.values().flatten().find_map(|(_, output)| {
684                    (output.initial_lsn > info.snapshot_lsn).then_some(
685                        DefiniteError::InvalidSnapshotLsn {
686                            initial_lsn: output.initial_lsn,
687                            snapshot_lsn: info.snapshot_lsn,
688                        },
689                    )
690                });
691                invalid_lsn.map_or(Ok(info), Err)
692            });
693            let snapshot_info = match snapshot_info {
694                Ok(info) => info,
695                Err(err) => {
696                    for (&oid, outputs) in tables_to_snapshot.iter() {
697                        for &output_index in outputs.keys() {
698                            if !config.responsible_for((oid, output_index)) {
699                                continue;
700                            }
701                            // We pick `u64::MAX` as the LSN which will (in practice) never conflict
702                            // any previously revealed portions of the TVC.
703                            let update =
704                                (Err(err.clone().into()), MzOffset::from(u64::MAX), Diff::ONE);
705                            raw_handles
706                                .give(output_index, &data_cap_sets[output_index][0], update)
707                                .await;
708                        }
709                    }
710                    if is_snapshot_leader {
711                        definite_error_handle.give(
712                            &definite_error_cap_set[0],
713                            ReplicationError::Definite(Rc::new(err)),
714                        );
715                    }
716                    return Ok(());
717                }
718            };
719            let snapshot_lsn = snapshot_info.snapshot_lsn;
720
721            // An export whose schema no longer matches the upstream is not copied. The
722            // replication reader seals it in place of a rewind.
723            let schema_errors: BTreeMap<usize, DefiniteError> = tables_to_snapshot
724                .values()
725                .flatten()
726                .filter_map(|(&output_index, info)| {
727                    let result = verify_schema(info.desc.oid, info, &snapshot_info.upstream_info);
728                    result.err().map(|error| (output_index, error))
729                })
730                .collect();
731
732            // Snapshot leader is already in identified transaction but all other workers need to enter it.
733            if !is_snapshot_leader {
734                let snapshot_id = &snapshot_info.snapshot_id;
735                trace!(%id, "timely-{worker_id} using snapshot id {snapshot_id:?}");
736                use_snapshot(&client, snapshot_id).await?;
737            }
738
739            // Since all workers snapshot all tables (each with different ctid ranges), we only
740            // emit rewind requests from the worker responsible for each output to avoid
741            // duplicates.
742            let emit_rewinds = |rewind_cap_set: &mut CapabilitySet<MzOffset>| {
743                for (&oid, outputs) in tables_to_snapshot.iter() {
744                    for (output_index, info) in outputs {
745                        if !config.responsible_for((oid, *output_index)) {
746                            continue;
747                        }
748                        let output_index = *output_index;
749                        let result = match schema_errors.get(&output_index) {
750                            Some(error) => SnapshotResult::Seal {
751                                output_index,
752                                error: error.clone(),
753                            },
754                            None => SnapshotResult::Rewind(RewindRequest {
755                                output_index,
756                                snapshot_lsn,
757                            }),
758                        };
759                        tracing::info!(
760                            %id,
761                            "timely-{worker_id} producing snapshot result {:?} for table {} output {output_index}",
762                            &result,
763                            info.desc.name
764                        );
765                        rewinds_handle.give(&rewind_cap_set[0], result);
766                    }
767                }
768                *rewind_cap_set = CapabilitySet::new();
769            };
770
771            // The rewind requests are what unblock the replication operator. With concurrent
772            // replication they are emitted now, before any data is copied, since the snapshot
773            // LSN is already known. The replication operator then reads the replication stream
774            // while the snapshot runs, staging its data in the dataflow until the snapshot
775            // completes. Otherwise they are emitted after the snapshot, which keeps the two
776            // phases serial and avoids that staging cost.
777            if concurrent_replication {
778                emit_rewinds(rewind_cap_set);
779            }
780
781            for (&output_index, info) in tables_to_snapshot.values().flatten() {
782                if !schema_errors.contains_key(&output_index) {
783                    snapshot_output(
784                        &config,
785                        &client,
786                        &mut raw_handles,
787                        &data_cap_sets[output_index][0],
788                        output_index,
789                        info,
790                        &snapshot_info,
791                    )
792                    .await?;
793                }
794                // A transient error above returns with the capability still held, which keeps a
795                // partially copied export from advancing.
796                if concurrent_replication {
797                    data_cap_sets[output_index] = CapabilitySet::new();
798                }
799            }
800
801            if !concurrent_replication {
802                emit_rewinds(rewind_cap_set);
803            }
804
805            // Failure scenario after we have produced the snapshot, but before a successful COMMIT
806            fail::fail_point!("pg_snapshot_failure", |_| Err(
807                TransientError::SyntheticError
808            ));
809
810            // The exporting worker should wait for all the other workers to commit before dropping
811            // its client since this is what holds the exported transaction alive.
812            if is_snapshot_leader {
813                tracing::info!(
814                    %id,
815                    "timely-{worker_id} (leader) finished COPY, waiting for all workers to finish"
816                );
817                *snapshot_cap_set = CapabilitySet::new();
818                while snapshot_input.next().await.is_some() {}
819                tracing::info!(%id, "timely-{worker_id} (leader) committing COPY transaction");
820                simple_query(&client, sql!("COMMIT")).await?;
821            } else {
822                tracing::info!(%id, "timely-{worker_id} committing COPY transaction");
823                simple_query(&client, sql!("COMMIT")).await?;
824                *snapshot_cap_set = CapabilitySet::new();
825            }
826            drop(client);
827            Ok(())
828        }))
829    });
830
831    // We now decode the COPY protocol and apply the cast expressions, with one decode operator
832    // per export.
833    let mut output_info = BTreeMap::new();
834    for outputs in table_info.into_values() {
835        for (output_index, info) in outputs {
836            output_info.insert(output_index, info);
837        }
838    }
839    let mut snapshot_updates = Vec::with_capacity(raw_streams.len());
840    for (output_index, raw_stream) in raw_streams.into_iter().enumerate() {
841        let info = output_info.get(&output_index).cloned();
842        let mut text_row = Row::default();
843        let mut final_row = Row::default();
844        let mut datum_vec = DatumVec::new();
845        let updates = raw_stream
846            .unary(
847                Pipeline,
848                &format!("PgCastSnapshotRows({output_index})"),
849                |_, _| {
850                    move |input, output| {
851                        input.for_each_stamp(|time, data| {
852                            let mut session = output.session(&time);
853                            for (event, time, diff) in data.flat_map(|data| data.drain(..)) {
854                                let info = info
855                                    .as_ref()
856                                    .expect("only exports with output info receive data");
857                                let event = event
858                                    .as_ref()
859                                    .map_err(|e: &DataflowError| e.clone())
860                                    .and_then(|bytes| {
861                                        decode_copy_row(bytes, info.casts.len(), &mut text_row)?;
862                                        let datums = datum_vec.borrow_with(&text_row);
863                                        super::cast_row(&info.casts, &datums, &mut final_row)?;
864                                        Ok(SourceMessage {
865                                            key: Row::default(),
866                                            value: final_row.clone(),
867                                            metadata: Row::default(),
868                                        })
869                                    });
870                                session.give((event, time, diff));
871                            }
872                        });
873                    }
874                },
875            )
876            .as_collection();
877        snapshot_updates.push(updates);
878    }
879
880    let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
881
882    (
883        snapshot_updates,
884        rewinds,
885        slot_ready,
886        errors,
887        button.press_on_drop(),
888    )
889}
890
891/// Copies this worker's share of the table of `info` to the data output `output_index` at
892/// `cap`.
893async fn snapshot_output(
894    config: &RawSourceCreationConfig,
895    client: &Client,
896    raw_handles: &mut SharedFuel<
897        MzOffset,
898        CapacityContainerBuilder<Vec<(Result<Bytes, DataflowError>, MzOffset, Diff)>>,
899    >,
900    cap: &Capability<MzOffset>,
901    output_index: usize,
902    info: &SourceOutputInfo,
903    snapshot_info: &SnapshotInfo,
904) -> Result<(), TransientError> {
905    let id = config.id;
906    let worker_id = config.worker_id;
907    let oid = info.desc.oid;
908    let snapshot_lsn = snapshot_info.snapshot_lsn;
909    let statistics = &config.statistics[&info.export_id];
910
911    pg_snapshot_failpoint_pause(&info.desc.name).await;
912
913    // Calculate this worker's ctid range based on estimated blocks.
914    // When estimated_blocks is 0 (PG < 14 or empty table), fall back to
915    // single-worker mode using responsible_for to pick the worker.
916    let block_count = snapshot_info
917        .table_block_counts
918        .get(&oid)
919        .copied()
920        .unwrap_or(0);
921    let Some(ctid_range) = worker_ctid_range(config, block_count, oid) else {
922        // This worker has no work for this table (more workers than blocks)
923        trace!(
924            %id,
925            "timely-{worker_id} no ctid range assigned for table {:?}({oid})",
926            info.desc.name
927        );
928        return Ok(());
929    };
930
931    trace!(
932        %id,
933        "timely-{worker_id} snapshotting table {:?}({oid}) output {output_index} \
934         @ {snapshot_lsn} with ctid range {:?}",
935        info.desc.name,
936        ctid_range
937    );
938
939    let namespace = Sql::ident(&info.desc.namespace);
940    let table = Sql::ident(&info.desc.name);
941    let column_list = Sql::join(info.desc.columns.iter().map(|c| Sql::ident(&c.name)), ",");
942
943    let ctid_filter = match ctid_range.end_block {
944        Some(end) => sql!(
945            "WHERE ctid >= '({},0)'::tid AND ctid < '({},0)'::tid",
946            ctid_range.start_block,
947            end
948        ),
949        None => sql!("WHERE ctid >= '({},0)'::tid", ctid_range.start_block),
950    };
951    let query = sql!(
952        "COPY (SELECT {} FROM {}.{} {}) TO STDOUT (FORMAT TEXT, DELIMITER '\t')",
953        column_list,
954        namespace,
955        table,
956        ctid_filter
957    );
958    let mut stream = pin!(client.copy_out_simple(query.as_str()).await?);
959
960    let mut snapshot_staged = 0;
961    while let Some(bytes) = stream.try_next().await? {
962        let update = (Ok(bytes), MzOffset::minimum(), Diff::ONE);
963        raw_handles.give(output_index, cap, update).await;
964        snapshot_staged += 1;
965        if snapshot_staged % 1000 == 0 {
966            statistics.set_snapshot_records_staged(snapshot_staged);
967        }
968    }
969    // final update for snapshot_staged, using the staged
970    // values as the total is an estimate
971    statistics.set_snapshot_records_staged(snapshot_staged);
972    Ok(())
973}
974
975/// Test-only pause point, run before any data of `table` is emitted. Configuring the failpoint as
976/// `pg_snapshot_pause=return(<table name>)` holds the snapshot of that table until the failpoint
977/// is deactivated. `return(<table name>:<millis>)` holds it for that long instead, so the snapshot
978/// completes in the same dataflow incarnation. Tables are snapshotted in OID order, so tables
979/// ordered before the paused one complete their snapshots.
980async fn pg_snapshot_failpoint_pause(table: &str) {
981    let held_since = std::time::Instant::now();
982    loop {
983        let held = fail::eval("pg_snapshot_pause", |payload| {
984            let (paused_table, millis) = payload
985                .as_deref()
986                .map(|p| p.split_once(':').unwrap_or((p, "")))
987                .unwrap_or_default();
988            paused_table == table
989                && millis.parse::<u64>().map_or(true, |millis| {
990                    held_since.elapsed() < Duration::from_millis(millis)
991                })
992        });
993        match held {
994            Some(true) => tokio::time::sleep(Duration::from_millis(100)).await,
995            _ => break,
996        }
997    }
998}
999
1000/// Starts a read-only transaction on the SQL session of `client` at a consistent LSN point by
1001/// creating a replication slot. Returns a snapshot identifier that can be imported in
1002/// other SQL session and the LSN of the consistent point.
1003async fn export_snapshot(
1004    client: &Client,
1005    slot: &str,
1006    temporary: bool,
1007    timeout: Duration,
1008) -> Result<(String, MzOffset), TransientError> {
1009    match export_snapshot_inner(client, slot, temporary, timeout).await {
1010        Ok(ok) => Ok(ok),
1011        Err(err) => {
1012            // We don't want to leave the client inside a failed tx
1013            simple_query(client, sql!("ROLLBACK;")).await?;
1014            Err(err)
1015        }
1016    }
1017}
1018
1019async fn export_snapshot_inner(
1020    client: &Client,
1021    slot: &str,
1022    temporary: bool,
1023    timeout: Duration,
1024) -> Result<(String, MzOffset), TransientError> {
1025    simple_query(
1026        client,
1027        sql!("BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;"),
1028    )
1029    .await?;
1030
1031    let query = if temporary {
1032        sql!(
1033            "CREATE_REPLICATION_SLOT {} TEMPORARY LOGICAL \"pgoutput\" USE_SNAPSHOT",
1034            Sql::ident(slot)
1035        )
1036    } else {
1037        sql!(
1038            "CREATE_REPLICATION_SLOT {} LOGICAL \"pgoutput\" USE_SNAPSHOT",
1039            Sql::ident(slot)
1040        )
1041    };
1042    let row = super::create_replication_slot(client, slot, query, timeout)
1043        .await?
1044        .expect("CREATE_REPLICATION_SLOT returns one row");
1045
1046    // When creating a replication slot postgres returns the LSN of its consistent point, which is
1047    // the LSN that must be passed to `START_REPLICATION` to cleanly transition from the snapshot
1048    // phase to the replication phase. `START_REPLICATION` includes all transactions that commit at
1049    // LSNs *greater than or equal* to the passed LSN. Therefore the snapshot phase must happen at
1050    // the greatest LSN that is not beyond the consistent point. That LSN is `consistent_point - 1`
1051    let consistent_point: PgLsn = row.get("consistent_point").unwrap().parse().unwrap();
1052    let consistent_point = u64::from(consistent_point)
1053        .checked_sub(1)
1054        .expect("consistent point is always non-zero");
1055
1056    let row = simple_query_opt(client, sql!("SELECT pg_export_snapshot();"))
1057        .await?
1058        .unwrap();
1059    let snapshot = row.get("pg_export_snapshot").unwrap().to_owned();
1060
1061    Ok((snapshot, MzOffset::from(consistent_point)))
1062}
1063
1064/// Starts a read-only transaction on the SQL session of `client` at a the consistent LSN point of
1065/// `snapshot`.
1066async fn use_snapshot(client: &Client, snapshot: &str) -> Result<(), TransientError> {
1067    simple_query(
1068        client,
1069        sql!("BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;"),
1070    )
1071    .await?;
1072    let query = sql!("SET TRANSACTION SNAPSHOT {};", Sql::literal(snapshot));
1073    simple_query(client, query).await?;
1074    Ok(())
1075}
1076
1077/// Decodes a row of `col_len` columns obtained from a text encoded COPY query into `row`.
1078fn decode_copy_row(data: &[u8], col_len: usize, row: &mut Row) -> Result<(), DefiniteError> {
1079    let mut packer = row.packer();
1080    let row_parser = mz_pgcopy::CopyTextFormatParser::new(data, b'\t', "\\N");
1081    let mut column_iter = row_parser.iter_raw_truncating(col_len);
1082    for _ in 0..col_len {
1083        let value = match column_iter.next() {
1084            Some(Ok(value)) => value,
1085            Some(Err(_)) => return Err(DefiniteError::InvalidCopyInput),
1086            None => return Err(DefiniteError::MissingColumn),
1087        };
1088        let datum = value.map(super::decode_utf8_text).transpose()?;
1089        packer.push(datum.unwrap_or(Datum::Null));
1090    }
1091    Ok(())
1092}
1093
1094/// Record the sizes of the tables being snapshotted in `PgSnapshotMetrics` and emit snapshot statistics for each export.
1095async fn report_snapshot_size(
1096    client: &Client,
1097    tables_to_snapshot: &BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
1098    metrics: PgSnapshotMetrics,
1099    config: &RawSourceCreationConfig,
1100    export_statistics: &BTreeMap<(u32, usize), SourceStatistics>,
1101) -> Result<(), anyhow::Error> {
1102    // TODO(guswynn): delete unused configs
1103    let snapshot_config = config.config.parameters.pg_snapshot_config;
1104
1105    for (&oid, outputs) in tables_to_snapshot {
1106        // Use the first output's desc to make the table name since it is the same for all outputs
1107        let Some((_, info)) = outputs.first_key_value() else {
1108            continue;
1109        };
1110        let table = sql!(
1111            "{}.{}",
1112            Sql::ident(&info.desc.namespace),
1113            Sql::ident(&info.desc.name)
1114        )
1115        .into_string();
1116        let stats = collect_table_statistics(
1117            client,
1118            snapshot_config,
1119            &info.desc.namespace,
1120            &info.desc.name,
1121            info.desc.oid,
1122        )
1123        .await?;
1124        metrics.record_table_count_latency(table, stats.count_latency);
1125        for &output_index in outputs.keys() {
1126            export_statistics[&(oid, output_index)].set_snapshot_records_known(stats.count);
1127            export_statistics[&(oid, output_index)].set_snapshot_records_staged(0);
1128        }
1129    }
1130    Ok(())
1131}
1132
1133#[derive(Default)]
1134struct TableStatistics {
1135    count: u64,
1136    count_latency: f64,
1137}
1138
1139async fn collect_table_statistics(
1140    client: &Client,
1141    config: PgSourceSnapshotConfig,
1142    schema: &str,
1143    table: &str,
1144    oid: u32,
1145) -> Result<TableStatistics, anyhow::Error> {
1146    use mz_ore::metrics::MetricsFutureExt;
1147    let mut stats = TableStatistics::default();
1148
1149    let estimate_query = sql!(
1150        "SELECT reltuples::bigint AS estimate_count FROM pg_class WHERE oid = {}",
1151        Sql::literal(&oid.to_string())
1152    );
1153    let estimate_row = simple_query_opt(client, estimate_query)
1154        .wall_time()
1155        .set_at(&mut stats.count_latency)
1156        .await?;
1157    stats.count = match estimate_row {
1158        Some(row) => row.get("estimate_count").unwrap().parse().unwrap_or(0),
1159        None => bail!("failed to get estimate count for {schema}.{table}"),
1160    };
1161
1162    // If the estimate is low enough we can attempt to get an exact count. Note that not yet
1163    // vacuumed tables will report zero rows here and there is a possibility that they are very
1164    // large. We accept this risk and we offer the feature flag as an escape hatch if it becomes
1165    // problematic.
1166    if config.collect_strict_count && stats.count < 1_000_000 {
1167        let count_query = sql!(
1168            "SELECT count(*) as count from {}.{}",
1169            Sql::ident(schema),
1170            Sql::ident(table)
1171        );
1172        let count_row = simple_query_opt(client, count_query)
1173            .wall_time()
1174            .set_at(&mut stats.count_latency)
1175            .await?;
1176        stats.count = match count_row {
1177            Some(row) => row.get("count").unwrap().parse().unwrap(),
1178            None => bail!("failed to get count for {schema}.{table}"),
1179        }
1180    }
1181
1182    Ok(stats)
1183}
1184
1185/// Validates that there are no blocking RLS polcicies on the tables and retrieves table schemas
1186/// for the given publication.
1187async fn retrieve_schema_info(
1188    connection_config: &Config,
1189    connection_context: &ConnectionContext,
1190    publication: &str,
1191    table_oids: &[Oid],
1192) -> Result<BTreeMap<u32, PostgresTableDesc>, PostgresError> {
1193    let schema_client = connection_config
1194        .connect(
1195            "snapshot schema info",
1196            &connection_context.ssh_tunnel_manager,
1197        )
1198        .await?;
1199    mz_postgres_util::validate_no_rls_policies(&schema_client, table_oids).await?;
1200    mz_postgres_util::publication_info(&schema_client, publication, Some(table_oids)).await
1201}