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