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::{RewindRequest, ensure_replication_prerequisites};
204use crate::source::postgres::{
205 DefiniteError, ReplicationError, SourceOutputInfo, TransientError, verify_schema,
206};
207use crate::source::types::{SharedFuel, SignaledFuture, SourceMessage, StackedCollection};
208use crate::statistics::SourceStatistics;
209
210/// Information broadcasted from the snapshot leader to all workers.
211/// This includes the transaction snapshot ID, LSN, and estimated block counts for each table.
212#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
213struct SnapshotInfo {
214 /// The exported transaction snapshot identifier.
215 snapshot_id: String,
216 /// The LSN at which the snapshot was taken.
217 snapshot_lsn: MzOffset,
218 /// Estimated number of blocks (pages) for each table, keyed by OID.
219 /// This is derived from `pg_class.relpages` and used to partition ctid ranges.
220 table_block_counts: BTreeMap<u32, u64>,
221 /// The current upstream schema of each table.
222 upstream_info: BTreeMap<u32, PostgresTableDesc>,
223}
224
225/// Represents a ctid range that a worker should snapshot.
226/// The range is [start_block, end_block) where end_block is optional (None means unbounded).
227#[derive(Debug)]
228struct CtidRange {
229 /// The starting block number (inclusive).
230 start_block: u64,
231 /// The ending block number (exclusive). None means unbounded (open-ended range).
232 end_block: Option<u64>,
233}
234
235/// Calculate the ctid range for a given worker based on estimated block count.
236///
237/// The table is partitioned by block number across all workers. Each worker gets a contiguous
238/// range of blocks. The last worker gets an open-ended range to handle any rows beyond the
239/// estimated block count.
240///
241/// When `estimated_blocks` is 0 (either because statistics are unavailable, the table appears
242/// empty, or PostgreSQL version < 14 doesn't support ctid range scans), the table is assigned
243/// to a single worker determined by `config.responsible_for(oid)` and that worker scans the
244/// full table.
245///
246/// Returns None if this worker has no work to do.
247fn worker_ctid_range(
248 config: &RawSourceCreationConfig,
249 estimated_blocks: u64,
250 oid: u32,
251) -> Option<CtidRange> {
252 // If estimated_blocks is 0, fall back to single-worker mode for this table.
253 // This handles:
254 // - PostgreSQL < 14 (ctid range scans not supported)
255 // - Tables that appear empty in statistics
256 // - Tables with stale/missing statistics
257 // The responsible worker scans the full table with an open-ended range.
258 if estimated_blocks == 0 {
259 let fallback = if config.responsible_for(oid) {
260 Some(CtidRange {
261 start_block: 0,
262 end_block: None,
263 })
264 } else {
265 None
266 };
267 return fallback;
268 }
269
270 let worker_id = u64::cast_from(config.worker_id);
271 let worker_count = u64::cast_from(config.worker_count);
272
273 // If there are more workers than blocks, only assign work to workers with id < estimated_blocks
274 // The last assigned worker still gets an open range.
275 let effective_worker_count = std::cmp::min(worker_count, estimated_blocks);
276
277 if worker_id >= effective_worker_count {
278 // This worker has no work to do
279 return None;
280 }
281
282 // Calculate start block for this worker (integer division distributes blocks evenly)
283 let start_block = worker_id * estimated_blocks / effective_worker_count;
284
285 // The last effective worker gets an open-ended range
286 let is_last_effective_worker = worker_id == effective_worker_count - 1;
287 if is_last_effective_worker {
288 Some(CtidRange {
289 start_block,
290 end_block: None,
291 })
292 } else {
293 let end_block = (worker_id + 1) * estimated_blocks / effective_worker_count;
294 Some(CtidRange {
295 start_block,
296 end_block: Some(end_block),
297 })
298 }
299}
300
301/// Estimate the number of blocks for each table from pg_class statistics.
302/// This is used to partition ctid ranges across workers.
303async fn estimate_table_block_counts(
304 client: &Client,
305 table_oids: &[u32],
306) -> Result<BTreeMap<u32, u64>, TransientError> {
307 if table_oids.is_empty() {
308 return Ok(BTreeMap::new());
309 }
310
311 // Query relpages for all tables at once.
312 let oid_list = Sql::join(table_oids.iter().copied().map(Sql::from), ",");
313 let query = sql!(
314 "SELECT oid, relpages FROM pg_class WHERE oid IN ({})",
315 oid_list
316 );
317
318 let mut block_counts = BTreeMap::new();
319 // Initialize all tables with 0 blocks (in case they're not in pg_class)
320 for &oid in table_oids {
321 block_counts.insert(oid, 0);
322 }
323
324 // Execute the query and collect results
325 let rows = simple_query(client, query).await?;
326 for msg in rows {
327 if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
328 let oid: u32 = row.get("oid").unwrap().parse().unwrap();
329 let relpages: i64 = row.get("relpages").unwrap().parse().unwrap_or(0);
330 // relpages can be -1 if never analyzed, treat as 0
331 let relpages = std::cmp::max(0, relpages).try_into().unwrap();
332 block_counts.insert(oid, relpages);
333 }
334 }
335
336 Ok(block_counts)
337}
338
339/// Renders the snapshot dataflow. See the module documentation for more information.
340pub(crate) fn render<'scope>(
341 scope: Scope<'scope, MzOffset>,
342 config: RawSourceCreationConfig,
343 connection: PostgresSourceConnection,
344 table_info: BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
345 metrics: PgSnapshotMetrics,
346) -> (
347 Vec<StackedCollection<'scope, MzOffset, Result<SourceMessage, DataflowError>>>,
348 StreamVec<'scope, MzOffset, RewindRequest>,
349 StreamVec<'scope, MzOffset, Infallible>,
350 StreamVec<'scope, MzOffset, ReplicationError>,
351 PressOnDropButton,
352) {
353 let op_name = format!("TableReader({})", config.id);
354 let mut builder = AsyncOperatorBuilder::new(op_name, scope.clone());
355
356 let (feedback_handle, feedback_data) = scope.feedback(Default::default());
357
358 // One data output port per source export, in output index order. With concurrent
359 // replication enabled each port is held at the minimum only while this operator still has
360 // snapshot data to emit for that export. When disabled all ports are held until the
361 // entire snapshot completes, so every export observes the same frontier.
362 let export_count = config.source_exports.len();
363 let mut raw_handles = Vec::with_capacity(export_count);
364 let mut raw_streams = Vec::with_capacity(export_count);
365 for _ in 0..export_count {
366 let (handle, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
367 raw_handles.push(handle);
368 raw_streams.push(stream);
369 }
370 let (rewinds_handle, rewinds) = builder.new_output::<CapacityContainerBuilder<_>>();
371 // This output is used to signal to the replication operator that the replication slot has been
372 // created. With the current state of execution serialization there isn't a lot of benefit
373 // of splitting the snapshot and replication phases into two operators.
374 // TODO(petrosagg): merge the two operators in one (while still maintaining separation as
375 // functions/modules)
376 let (_, slot_ready) = builder.new_output::<CapacityContainerBuilder<_>>();
377 let (snapshot_handle, snapshot) = builder.new_output::<CapacityContainerBuilder<_>>();
378 let (definite_error_handle, definite_errors) =
379 builder.new_output::<CapacityContainerBuilder<_>>();
380
381 // This operator needs to broadcast data to itself in order to synchronize the transaction
382 // snapshot. However, none of the feedback capabilities result in output messages and for the
383 // feedback edge specifically having a default conncetion would result in a loop.
384 let mut snapshot_input = builder.new_disconnected_input(feedback_data, Pipeline);
385
386 // The export id must be sent to all workers, so we broadcast the feedback connection
387 snapshot.broadcast().connect_loop(feedback_handle);
388
389 let is_snapshot_leader = config.responsible_for("snapshot_leader");
390
391 // A global view of all outputs that will be snapshot by all workers.
392 let mut all_outputs = vec![];
393 // Table info for tables that need snapshotting. All workers will snapshot all tables,
394 // but each worker will handle a different ctid range within each table.
395 let mut tables_to_snapshot = BTreeMap::new();
396 // A collection of `SourceStatistics` to update for a given Oid. Same info exists in table_info,
397 // but this avoids having to iterate + map each time the statistics are needed.
398 let mut export_statistics = BTreeMap::new();
399 for (table, outputs) in table_info.iter() {
400 for (&output_index, output) in outputs {
401 if *output.resume_upper != [MzOffset::minimum()] {
402 // Already has been snapshotted.
403 continue;
404 }
405 all_outputs.push(output_index);
406 tables_to_snapshot
407 .entry(*table)
408 .or_insert_with(BTreeMap::new)
409 .insert(output_index, output.clone());
410 let statistics = config
411 .statistics
412 .get(&output.export_id)
413 .expect("statistics are initialized")
414 .clone();
415 export_statistics.insert((*table, output_index), statistics);
416 }
417 }
418
419 // Read at render time, matching the value the persist sink reads in the same dataflow build.
420 // A read in the async body can observe an `UpdateConfiguration` sequenced after this dataflow's
421 // creation and disagree with the sink because the `ConfigSet` is updated in place.
422 // A nonzero lookahead is the switch, see `STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD`.
423 let concurrent_replication = !STORAGE_PERSIST_SINK_DESCRIPTION_LOOKAHEAD
424 .get(config.config.config_set())
425 .is_zero();
426
427 let (button, transient_errors) = builder.build_fallible(move |caps| {
428 let busy_signal = Arc::clone(&config.busy_signal);
429 Box::pin(SignaledFuture::new(busy_signal, async move {
430 let id = config.id;
431 let worker_id = config.worker_id;
432 let (data_cap_sets, caps) = caps.split_at_mut(export_count);
433 let [
434 rewind_cap_set,
435 slot_ready_cap_set,
436 snapshot_cap_set,
437 definite_error_cap_set,
438 ]: &mut [_; 4] = caps.try_into().unwrap();
439 let mut raw_handles = SharedFuel::new(raw_handles);
440
441 // This operator only ever emits snapshot data for the exports it snapshots. With
442 // concurrent replication the ports of all other exports are closed up front, so
443 // their frontiers are determined by the replication operator alone and they make
444 // progress while the snapshot runs. Ports of snapshotted exports close one by one
445 // as each export's copy finishes on this worker.
446 if concurrent_replication {
447 for (output_index, cap_set) in data_cap_sets.iter_mut().enumerate() {
448 if !all_outputs.contains(&output_index) {
449 *cap_set = CapabilitySet::new();
450 }
451 }
452 }
453
454 let connection_config = connection
455 .connection
456 .config(
457 &config.config.connection_context.secrets_reader,
458 &config.config,
459 InTask::Yes,
460 )
461 .await?;
462
463 let slot_creation_timeout =
464 PG_SOURCE_REPLICATION_SLOT_CREATION_TIMEOUT.get(config.config.config_set());
465
466 // The snapshot operator is responsible for creating the replication slot(s).
467 // This first slot is the permanent slot that will be used for reading the replication
468 // stream. A temporary slot is created further on to capture table snapshots.
469 let replication_client = if is_snapshot_leader {
470 tracing::info!(
471 %id,
472 "timely-{worker_id} (leader) initializing table reader \
473 with {} tables to snapshot",
474 tables_to_snapshot.len()
475 );
476 let client = connection_config
477 .connect_replication(&config.config.connection_context.ssh_tunnel_manager)
478 .await?;
479 let main_slot = &connection.publication_details.slot;
480
481 tracing::info!(%id, "ensuring replication slot {main_slot} exists");
482 super::ensure_replication_slot(&client, main_slot, slot_creation_timeout).await?;
483 Some(client)
484 } else {
485 None
486 };
487 *slot_ready_cap_set = CapabilitySet::new();
488
489 // Nothing needs to be snapshot.
490 if all_outputs.is_empty() {
491 trace!(%id, "no exports to snapshot");
492 // Note we do not emit a `ProgressStatisticsUpdate::Snapshot` update here,
493 // as we do not want to attempt to override the current value with 0. We
494 // just leave it null.
495 return Ok(());
496 }
497
498 // A worker *must* emit a count even if not responsible for snapshotting a table
499 // as statistic summarization will return null if any worker hasn't set a value.
500 // This will also reset snapshot stats for any exports not snapshotting.
501 // If no workers need to snapshot, then avoid emitting these as they will clear
502 // previous stats.
503 for statistics in config.statistics.values() {
504 statistics.set_snapshot_records_known(0);
505 statistics.set_snapshot_records_staged(0);
506 }
507
508 // Collect table OIDs for block count estimation
509 let table_oids: Vec<u32> = tables_to_snapshot.keys().copied().collect();
510
511 // replication client is only set if this worker is the snapshot leader
512 let client = match replication_client {
513 Some(client) => 'leader: {
514 // The replication operator detects the same conditions before it reads the
515 // stream. With concurrent replication that happens during the copy, and a
516 // definite error found there cannot commit while this operator holds the
517 // exports at the minimum. The dataflow then restarts and repeats the copy for
518 // as long as the condition holds. Checking here first fails before any copy.
519 //
520 // Definite errors are broadcast in place of the snapshot info so that every
521 // worker returns and releases its capabilities. A worker left waiting for the
522 // snapshot info holds the exports at the minimum.
523 let metadata_client = connection_config
524 .connect(
525 "snapshot metadata",
526 &config.config.connection_context.ssh_tunnel_manager,
527 )
528 .await?;
529 let slot_metadata = super::fetch_slot_metadata(
530 &metadata_client,
531 &connection.publication_details.slot,
532 PG_FETCH_SLOT_RESUME_LSN_INTERVAL.get(config.config.config_set()),
533 )
534 .await?;
535 let prerequisites = if slot_metadata.invalidated {
536 Err(DefiniteError::InvalidReplicationSlot)
537 } else {
538 ensure_replication_prerequisites(
539 &metadata_client,
540 &client,
541 &connection,
542 config.config.config_set(),
543 )
544 .await?
545 };
546 if let Err(err) = prerequisites {
547 snapshot_handle.give(&snapshot_cap_set[0], Err(err));
548 break 'leader client;
549 }
550
551 let tmp_slot = format!("mzsnapshot_{}", uuid::Uuid::new_v4()).replace('-', "");
552 tracing::info!(
553 %id,
554 "timely-{worker_id} (leader) creating temporary replication slot {tmp_slot}"
555 );
556 let (snapshot_id, snapshot_lsn) =
557 export_snapshot(&client, &tmp_slot, true, slot_creation_timeout).await?;
558 tracing::info!(
559 %id,
560 "timely-{worker_id} (leader) exported snapshot {snapshot_id} \
561 @ {snapshot_lsn}"
562 );
563
564 // Check PostgreSQL version. Ctid range scans are only efficient on PG >= 14
565 // due to improvements in TID range scan support.
566 let pg_version = get_pg_major_version(&client).await?;
567
568 // Estimate block counts for all tables from pg_class statistics.
569 // This must be done by the leader and broadcasted to ensure all workers
570 // use the same estimates for ctid range partitioning.
571 //
572 // For PostgreSQL < 14, we set all block counts to 0 to fall back to
573 // single-worker-per-table mode, as ctid range scans are not well supported.
574 let table_block_counts = if pg_version >= 14 {
575 estimate_table_block_counts(&client, &table_oids).await?
576 } else {
577 trace!(
578 %id,
579 "timely-{worker_id} PostgreSQL version {pg_version} < 14, \
580 falling back to single-worker-per-table snapshot mode"
581 );
582 // Return all zeros to trigger fallback mode
583 table_oids.iter().map(|&oid| (oid, 0u64)).collect()
584 };
585
586 report_snapshot_size(
587 &client,
588 &tables_to_snapshot,
589 metrics,
590 &config,
591 &export_statistics,
592 )
593 .await?;
594
595 let upstream_info = {
596 // As part of retrieving the schema info, RLS policies are checked to ensure the
597 // snapshot can successfully read the tables. RLS policy errors are treated as
598 // transient, as the customer can simply add the BYPASSRLS to the PG account
599 // used by MZ.
600 match retrieve_schema_info(
601 &connection_config,
602 &config.config.connection_context,
603 &connection.publication,
604 &table_oids)
605 .await
606 {
607 // If the replication stream cannot be obtained in a definite way there is
608 // nothing else to do. These errors are not retractable.
609 Err(PostgresError::PublicationMissing(publication)) => {
610 let err = DefiniteError::PublicationDropped(publication);
611 snapshot_handle.give(&snapshot_cap_set[0], Err(err));
612 break 'leader client;
613 },
614 Err(e) => Err(TransientError::from(e))?,
615 Ok(i) => i,
616 }
617 };
618
619 let snapshot_info = SnapshotInfo {
620 snapshot_id,
621 snapshot_lsn,
622 upstream_info,
623 table_block_counts,
624 };
625 trace!(
626 %id,
627 "timely-{worker_id} exporting snapshot info {snapshot_info:?}");
628 snapshot_handle.give(&snapshot_cap_set[0], Ok(snapshot_info));
629
630 client
631 }
632 None => {
633 // Only the snapshot leader needs a replication connection.
634 let task_name = format!("timely-{worker_id} PG snapshotter");
635 connection_config
636 .connect(
637 &task_name,
638 &config.config.connection_context.ssh_tunnel_manager,
639 )
640 .await?
641 }
642 };
643
644 // Configure statement_timeout based on param. We want to be able to
645 // override the server value here in case it's set too low,
646 // respective to the size of the data we need to copy.
647 super::set_timeout(
648 &client,
649 "statement_timeout",
650 config
651 .config
652 .parameters
653 .pg_source_snapshot_statement_timeout,
654 )
655 .await?;
656
657 let snapshot_info = loop {
658 match snapshot_input.next().await {
659 Some(AsyncEvent::Data(_, mut data)) => {
660 break data.pop().expect("snapshot sent above")
661 }
662 Some(AsyncEvent::Progress(_)) => continue,
663 None => panic!(
664 "feedback closed \
665 before sending snapshot info"
666 ),
667 }
668 };
669
670 // The snapshot transaction starts after every output's schema was captured during
671 // purification, so no output's initial LSN can exceed the snapshot LSN. A violation
672 // means the upstream went back in time, which would leave the rewind range the
673 // replication operator subtracts unable to reach the snapshot.
674 let snapshot_info = snapshot_info.and_then(|info| {
675 let invalid_lsn = tables_to_snapshot.values().flatten().find_map(|(_, output)| {
676 (output.initial_lsn > info.snapshot_lsn).then_some(
677 DefiniteError::InvalidSnapshotLsn {
678 initial_lsn: output.initial_lsn,
679 snapshot_lsn: info.snapshot_lsn,
680 },
681 )
682 });
683 invalid_lsn.map_or(Ok(info), Err)
684 });
685 let snapshot_info = match snapshot_info {
686 Ok(info) => info,
687 Err(err) => {
688 for (&oid, outputs) in tables_to_snapshot.iter() {
689 for &output_index in outputs.keys() {
690 if !config.responsible_for((oid, output_index)) {
691 continue;
692 }
693 // We pick `u64::MAX` as the LSN which will (in practice) never conflict
694 // any previously revealed portions of the TVC.
695 let update =
696 (Err(err.clone().into()), MzOffset::from(u64::MAX), Diff::ONE);
697 raw_handles
698 .give(output_index, &data_cap_sets[output_index][0], update)
699 .await;
700 }
701 }
702 if is_snapshot_leader {
703 definite_error_handle.give(
704 &definite_error_cap_set[0],
705 ReplicationError::Definite(Rc::new(err)),
706 );
707 }
708 return Ok(());
709 }
710 };
711 let snapshot_lsn = snapshot_info.snapshot_lsn;
712
713 // Snapshot leader is already in identified transaction but all other workers need to enter it.
714 if !is_snapshot_leader {
715 let snapshot_id = &snapshot_info.snapshot_id;
716 trace!(%id, "timely-{worker_id} using snapshot id {snapshot_id:?}");
717 use_snapshot(&client, snapshot_id).await?;
718 }
719
720 // Since all workers snapshot all tables (each with different ctid ranges), we only
721 // emit rewind requests from the worker responsible for each output to avoid
722 // duplicates.
723 let emit_rewinds = |rewind_cap_set: &mut CapabilitySet<MzOffset>| {
724 for (&oid, outputs) in tables_to_snapshot.iter() {
725 for (output_index, info) in outputs {
726 if !config.responsible_for((oid, *output_index)) {
727 continue;
728 }
729 trace!(%id, "timely-{worker_id} producing rewind request for table {} output {output_index}", info.desc.name);
730 let req = RewindRequest { output_index: *output_index, snapshot_lsn };
731 rewinds_handle.give(&rewind_cap_set[0], req);
732 }
733 }
734 *rewind_cap_set = CapabilitySet::new();
735 };
736
737 // The rewind requests are what unblock the replication operator. With concurrent
738 // replication they are emitted now, before any data is copied, since the snapshot
739 // LSN is already known. The replication operator then reads the replication stream
740 // while the snapshot runs, staging its data in the dataflow until the snapshot
741 // completes. Otherwise they are emitted after the snapshot, which keeps the two
742 // phases serial and avoids that staging cost.
743 if concurrent_replication {
744 emit_rewinds(rewind_cap_set);
745 }
746
747 for (&output_index, info) in tables_to_snapshot.values().flatten() {
748 snapshot_output(
749 &config,
750 &client,
751 &mut raw_handles,
752 &data_cap_sets[output_index][0],
753 output_index,
754 info,
755 &snapshot_info,
756 )
757 .await?;
758 // A transient error above returns with the capability still held, which keeps a
759 // partially copied export from advancing.
760 if concurrent_replication {
761 data_cap_sets[output_index] = CapabilitySet::new();
762 }
763 }
764
765 if !concurrent_replication {
766 emit_rewinds(rewind_cap_set);
767 }
768
769 // Failure scenario after we have produced the snapshot, but before a successful COMMIT
770 fail::fail_point!("pg_snapshot_failure", |_| Err(
771 TransientError::SyntheticError
772 ));
773
774 // The exporting worker should wait for all the other workers to commit before dropping
775 // its client since this is what holds the exported transaction alive.
776 if is_snapshot_leader {
777 tracing::info!(
778 %id,
779 "timely-{worker_id} (leader) finished COPY, waiting for all workers to finish"
780 );
781 *snapshot_cap_set = CapabilitySet::new();
782 while snapshot_input.next().await.is_some() {}
783 tracing::info!(%id, "timely-{worker_id} (leader) committing COPY transaction");
784 simple_query(&client, sql!("COMMIT")).await?;
785 } else {
786 tracing::info!(%id, "timely-{worker_id} committing COPY transaction");
787 simple_query(&client, sql!("COMMIT")).await?;
788 *snapshot_cap_set = CapabilitySet::new();
789 }
790 drop(client);
791 Ok(())
792 }))
793 });
794
795 // We now decode the COPY protocol and apply the cast expressions, with one decode operator
796 // per export.
797 let mut output_info = BTreeMap::new();
798 for outputs in table_info.into_values() {
799 for (output_index, info) in outputs {
800 output_info.insert(output_index, info);
801 }
802 }
803 let mut snapshot_updates = Vec::with_capacity(raw_streams.len());
804 for (output_index, raw_stream) in raw_streams.into_iter().enumerate() {
805 let info = output_info.get(&output_index).cloned();
806 let mut text_row = Row::default();
807 let mut final_row = Row::default();
808 let mut datum_vec = DatumVec::new();
809 let updates = raw_stream
810 .unary(
811 Pipeline,
812 &format!("PgCastSnapshotRows({output_index})"),
813 |_, _| {
814 move |input, output| {
815 input.for_each_time(|time, data| {
816 let mut session = output.session(&time);
817 for (event, time, diff) in data.flat_map(|data| data.drain(..)) {
818 let info = info
819 .as_ref()
820 .expect("only exports with output info receive data");
821 let event = event
822 .as_ref()
823 .map_err(|e: &DataflowError| e.clone())
824 .and_then(|bytes| {
825 decode_copy_row(bytes, info.casts.len(), &mut text_row)?;
826 let datums = datum_vec.borrow_with(&text_row);
827 super::cast_row(&info.casts, &datums, &mut final_row)?;
828 Ok(SourceMessage {
829 key: Row::default(),
830 value: final_row.clone(),
831 metadata: Row::default(),
832 })
833 });
834 session.give((event, time, diff));
835 }
836 });
837 }
838 },
839 )
840 .as_collection();
841 snapshot_updates.push(updates);
842 }
843
844 let errors = definite_errors.concat(transient_errors.map(ReplicationError::from));
845
846 (
847 snapshot_updates,
848 rewinds,
849 slot_ready,
850 errors,
851 button.press_on_drop(),
852 )
853}
854
855/// Copies this worker's share of the table of `info` to the data output `output_index` at
856/// `cap`. A schema mismatch is emitted as a definite error in place of the table's rows.
857async fn snapshot_output(
858 config: &RawSourceCreationConfig,
859 client: &Client,
860 raw_handles: &mut SharedFuel<
861 MzOffset,
862 CapacityContainerBuilder<Vec<(Result<Bytes, DataflowError>, MzOffset, Diff)>>,
863 >,
864 cap: &Capability<MzOffset>,
865 output_index: usize,
866 info: &SourceOutputInfo,
867 snapshot_info: &SnapshotInfo,
868) -> Result<(), TransientError> {
869 let id = config.id;
870 let worker_id = config.worker_id;
871 let oid = info.desc.oid;
872 let snapshot_lsn = snapshot_info.snapshot_lsn;
873 let statistics = &config.statistics[&info.export_id];
874
875 pg_snapshot_failpoint_pause(&info.desc.name).await;
876
877 if let Err(err) = verify_schema(oid, info, &snapshot_info.upstream_info) {
878 let update = (Err(err.into()), MzOffset::minimum(), Diff::ONE);
879 raw_handles.give(output_index, cap, update).await;
880 return Ok(());
881 }
882
883 // Calculate this worker's ctid range based on estimated blocks.
884 // When estimated_blocks is 0 (PG < 14 or empty table), fall back to
885 // single-worker mode using responsible_for to pick the worker.
886 let block_count = snapshot_info
887 .table_block_counts
888 .get(&oid)
889 .copied()
890 .unwrap_or(0);
891 let Some(ctid_range) = worker_ctid_range(config, block_count, oid) else {
892 // This worker has no work for this table (more workers than blocks)
893 trace!(
894 %id,
895 "timely-{worker_id} no ctid range assigned for table {:?}({oid})",
896 info.desc.name
897 );
898 return Ok(());
899 };
900
901 trace!(
902 %id,
903 "timely-{worker_id} snapshotting table {:?}({oid}) output {output_index} \
904 @ {snapshot_lsn} with ctid range {:?}",
905 info.desc.name,
906 ctid_range
907 );
908
909 let namespace = Sql::ident(&info.desc.namespace);
910 let table = Sql::ident(&info.desc.name);
911 let column_list = Sql::join(info.desc.columns.iter().map(|c| Sql::ident(&c.name)), ",");
912
913 let ctid_filter = match ctid_range.end_block {
914 Some(end) => sql!(
915 "WHERE ctid >= '({},0)'::tid AND ctid < '({},0)'::tid",
916 ctid_range.start_block,
917 end
918 ),
919 None => sql!("WHERE ctid >= '({},0)'::tid", ctid_range.start_block),
920 };
921 let query = sql!(
922 "COPY (SELECT {} FROM {}.{} {}) TO STDOUT (FORMAT TEXT, DELIMITER '\t')",
923 column_list,
924 namespace,
925 table,
926 ctid_filter
927 );
928 let mut stream = pin!(client.copy_out_simple(query.as_str()).await?);
929
930 let mut snapshot_staged = 0;
931 while let Some(bytes) = stream.try_next().await? {
932 let update = (Ok(bytes), MzOffset::minimum(), Diff::ONE);
933 raw_handles.give(output_index, cap, update).await;
934 snapshot_staged += 1;
935 if snapshot_staged % 1000 == 0 {
936 statistics.set_snapshot_records_staged(snapshot_staged);
937 }
938 }
939 // final update for snapshot_staged, using the staged
940 // values as the total is an estimate
941 statistics.set_snapshot_records_staged(snapshot_staged);
942 Ok(())
943}
944
945/// Test-only pause point, run before any data of `table` is emitted. Configuring the failpoint as
946/// `pg_snapshot_pause=return(<table name>)` holds the snapshot of that table until the failpoint
947/// is deactivated. `return(<table name>:<millis>)` holds it for that long instead, so the snapshot
948/// completes in the same dataflow incarnation. Tables are snapshotted in OID order, so tables
949/// ordered before the paused one complete their snapshots.
950async fn pg_snapshot_failpoint_pause(table: &str) {
951 let held_since = std::time::Instant::now();
952 loop {
953 let held = fail::eval("pg_snapshot_pause", |payload| {
954 let (paused_table, millis) = payload
955 .as_deref()
956 .map(|p| p.split_once(':').unwrap_or((p, "")))
957 .unwrap_or_default();
958 paused_table == table
959 && millis.parse::<u64>().map_or(true, |millis| {
960 held_since.elapsed() < Duration::from_millis(millis)
961 })
962 });
963 match held {
964 Some(true) => tokio::time::sleep(Duration::from_millis(100)).await,
965 _ => break,
966 }
967 }
968}
969
970/// Starts a read-only transaction on the SQL session of `client` at a consistent LSN point by
971/// creating a replication slot. Returns a snapshot identifier that can be imported in
972/// other SQL session and the LSN of the consistent point.
973async fn export_snapshot(
974 client: &Client,
975 slot: &str,
976 temporary: bool,
977 timeout: Duration,
978) -> Result<(String, MzOffset), TransientError> {
979 match export_snapshot_inner(client, slot, temporary, timeout).await {
980 Ok(ok) => Ok(ok),
981 Err(err) => {
982 // We don't want to leave the client inside a failed tx
983 simple_query(client, sql!("ROLLBACK;")).await?;
984 Err(err)
985 }
986 }
987}
988
989async fn export_snapshot_inner(
990 client: &Client,
991 slot: &str,
992 temporary: bool,
993 timeout: Duration,
994) -> Result<(String, MzOffset), TransientError> {
995 simple_query(
996 client,
997 sql!("BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;"),
998 )
999 .await?;
1000
1001 let query = if temporary {
1002 sql!(
1003 "CREATE_REPLICATION_SLOT {} TEMPORARY LOGICAL \"pgoutput\" USE_SNAPSHOT",
1004 Sql::ident(slot)
1005 )
1006 } else {
1007 sql!(
1008 "CREATE_REPLICATION_SLOT {} LOGICAL \"pgoutput\" USE_SNAPSHOT",
1009 Sql::ident(slot)
1010 )
1011 };
1012 let row = super::create_replication_slot(client, slot, query, timeout)
1013 .await?
1014 .expect("CREATE_REPLICATION_SLOT returns one row");
1015
1016 // When creating a replication slot postgres returns the LSN of its consistent point, which is
1017 // the LSN that must be passed to `START_REPLICATION` to cleanly transition from the snapshot
1018 // phase to the replication phase. `START_REPLICATION` includes all transactions that commit at
1019 // LSNs *greater than or equal* to the passed LSN. Therefore the snapshot phase must happen at
1020 // the greatest LSN that is not beyond the consistent point. That LSN is `consistent_point - 1`
1021 let consistent_point: PgLsn = row.get("consistent_point").unwrap().parse().unwrap();
1022 let consistent_point = u64::from(consistent_point)
1023 .checked_sub(1)
1024 .expect("consistent point is always non-zero");
1025
1026 let row = simple_query_opt(client, sql!("SELECT pg_export_snapshot();"))
1027 .await?
1028 .unwrap();
1029 let snapshot = row.get("pg_export_snapshot").unwrap().to_owned();
1030
1031 Ok((snapshot, MzOffset::from(consistent_point)))
1032}
1033
1034/// Starts a read-only transaction on the SQL session of `client` at a the consistent LSN point of
1035/// `snapshot`.
1036async fn use_snapshot(client: &Client, snapshot: &str) -> Result<(), TransientError> {
1037 simple_query(
1038 client,
1039 sql!("BEGIN READ ONLY ISOLATION LEVEL REPEATABLE READ;"),
1040 )
1041 .await?;
1042 let query = sql!("SET TRANSACTION SNAPSHOT {};", Sql::literal(snapshot));
1043 simple_query(client, query).await?;
1044 Ok(())
1045}
1046
1047/// Decodes a row of `col_len` columns obtained from a text encoded COPY query into `row`.
1048fn decode_copy_row(data: &[u8], col_len: usize, row: &mut Row) -> Result<(), DefiniteError> {
1049 let mut packer = row.packer();
1050 let row_parser = mz_pgcopy::CopyTextFormatParser::new(data, b'\t', "\\N");
1051 let mut column_iter = row_parser.iter_raw_truncating(col_len);
1052 for _ in 0..col_len {
1053 let value = match column_iter.next() {
1054 Some(Ok(value)) => value,
1055 Some(Err(_)) => return Err(DefiniteError::InvalidCopyInput),
1056 None => return Err(DefiniteError::MissingColumn),
1057 };
1058 let datum = value.map(super::decode_utf8_text).transpose()?;
1059 packer.push(datum.unwrap_or(Datum::Null));
1060 }
1061 Ok(())
1062}
1063
1064/// Record the sizes of the tables being snapshotted in `PgSnapshotMetrics` and emit snapshot statistics for each export.
1065async fn report_snapshot_size(
1066 client: &Client,
1067 tables_to_snapshot: &BTreeMap<u32, BTreeMap<usize, SourceOutputInfo>>,
1068 metrics: PgSnapshotMetrics,
1069 config: &RawSourceCreationConfig,
1070 export_statistics: &BTreeMap<(u32, usize), SourceStatistics>,
1071) -> Result<(), anyhow::Error> {
1072 // TODO(guswynn): delete unused configs
1073 let snapshot_config = config.config.parameters.pg_snapshot_config;
1074
1075 for (&oid, outputs) in tables_to_snapshot {
1076 // Use the first output's desc to make the table name since it is the same for all outputs
1077 let Some((_, info)) = outputs.first_key_value() else {
1078 continue;
1079 };
1080 let table = sql!(
1081 "{}.{}",
1082 Sql::ident(&info.desc.namespace),
1083 Sql::ident(&info.desc.name)
1084 )
1085 .into_string();
1086 let stats = collect_table_statistics(
1087 client,
1088 snapshot_config,
1089 &info.desc.namespace,
1090 &info.desc.name,
1091 info.desc.oid,
1092 )
1093 .await?;
1094 metrics.record_table_count_latency(table, stats.count_latency);
1095 for &output_index in outputs.keys() {
1096 export_statistics[&(oid, output_index)].set_snapshot_records_known(stats.count);
1097 export_statistics[&(oid, output_index)].set_snapshot_records_staged(0);
1098 }
1099 }
1100 Ok(())
1101}
1102
1103#[derive(Default)]
1104struct TableStatistics {
1105 count: u64,
1106 count_latency: f64,
1107}
1108
1109async fn collect_table_statistics(
1110 client: &Client,
1111 config: PgSourceSnapshotConfig,
1112 schema: &str,
1113 table: &str,
1114 oid: u32,
1115) -> Result<TableStatistics, anyhow::Error> {
1116 use mz_ore::metrics::MetricsFutureExt;
1117 let mut stats = TableStatistics::default();
1118
1119 let estimate_query = sql!(
1120 "SELECT reltuples::bigint AS estimate_count FROM pg_class WHERE oid = {}",
1121 Sql::literal(&oid.to_string())
1122 );
1123 let estimate_row = simple_query_opt(client, estimate_query)
1124 .wall_time()
1125 .set_at(&mut stats.count_latency)
1126 .await?;
1127 stats.count = match estimate_row {
1128 Some(row) => row.get("estimate_count").unwrap().parse().unwrap_or(0),
1129 None => bail!("failed to get estimate count for {schema}.{table}"),
1130 };
1131
1132 // If the estimate is low enough we can attempt to get an exact count. Note that not yet
1133 // vacuumed tables will report zero rows here and there is a possibility that they are very
1134 // large. We accept this risk and we offer the feature flag as an escape hatch if it becomes
1135 // problematic.
1136 if config.collect_strict_count && stats.count < 1_000_000 {
1137 let count_query = sql!(
1138 "SELECT count(*) as count from {}.{}",
1139 Sql::ident(schema),
1140 Sql::ident(table)
1141 );
1142 let count_row = simple_query_opt(client, count_query)
1143 .wall_time()
1144 .set_at(&mut stats.count_latency)
1145 .await?;
1146 stats.count = match count_row {
1147 Some(row) => row.get("count").unwrap().parse().unwrap(),
1148 None => bail!("failed to get count for {schema}.{table}"),
1149 }
1150 }
1151
1152 Ok(stats)
1153}
1154
1155/// Validates that there are no blocking RLS polcicies on the tables and retrieves table schemas
1156/// for the given publication.
1157async fn retrieve_schema_info(
1158 connection_config: &Config,
1159 connection_context: &ConnectionContext,
1160 publication: &str,
1161 table_oids: &[Oid],
1162) -> Result<BTreeMap<u32, PostgresTableDesc>, PostgresError> {
1163 let schema_client = connection_config
1164 .connect(
1165 "snapshot schema info",
1166 &connection_context.ssh_tunnel_manager,
1167 )
1168 .await?;
1169 mz_postgres_util::validate_no_rls_policies(&schema_client, table_oids).await?;
1170 mz_postgres_util::publication_info(&schema_client, publication, Some(table_oids)).await
1171}