mod events;
mod plan;
mod rows;
use crate::connection::PgReplicationConnection;
use crate::error::{ReplicationError, Result};
use crate::prelude::*;
use crate::sql_builder::quote_literal;
use crate::stream::LogicalReplicationStream;
use crate::types::Lsn;
use tokio_util::sync::CancellationToken;
const SNAPSHOT_TAKEN: &str =
"the stream and reader are moved out only by rows(), which consumes self";
pub use events::SnapshotEvents;
pub use plan::SnapshotTable;
pub use rows::{SnapshotRow, SnapshotRows};
use plan::CatalogRow;
#[allow(clippy::large_enum_variant)]
#[must_use = "the replication stream is inside this value; consume it to get it back"]
pub enum SnapshotOutcome {
Available(Snapshot),
Unavailable(LogicalReplicationStream),
}
impl SnapshotOutcome {
#[inline]
pub fn is_available(&self) -> bool {
matches!(self, SnapshotOutcome::Available(_))
}
pub async fn skip(self) -> Result<LogicalReplicationStream> {
match self {
SnapshotOutcome::Available(snapshot) => snapshot.abandon().await,
SnapshotOutcome::Unavailable(stream) => Ok(stream),
}
}
}
#[must_use = "the replication stream is inside this value; consume it with \
rows/events/abandon to get it back"]
pub struct Snapshot {
stream: Option<LogicalReplicationStream>,
reader: Option<PgReplicationConnection>,
tables: Vec<SnapshotTable>,
consistent_point: Lsn,
cancellation_token: CancellationToken,
}
impl Snapshot {
#[inline]
pub fn tables(&self) -> &[SnapshotTable] {
&self.tables
}
pub fn retain_tables(&mut self, predicate: impl FnMut(&SnapshotTable) -> bool) {
self.tables.retain(predicate);
}
#[inline]
pub fn consistent_point(&self) -> Lsn {
self.consistent_point
}
#[inline]
pub fn stream(&self) -> &LogicalReplicationStream {
self.stream.as_ref().expect(SNAPSHOT_TAKEN)
}
#[inline]
pub fn with_cancellation(mut self, token: CancellationToken) -> Self {
self.cancellation_token = token;
self
}
pub fn rows(mut self) -> SnapshotRows {
SnapshotRows::new(
self.stream.take().expect(SNAPSHOT_TAKEN),
self.reader.take().expect(SNAPSHOT_TAKEN),
core::mem::take(&mut self.tables),
self.consistent_point,
self.cancellation_token.clone(),
)
}
pub fn events(self) -> SnapshotEvents {
SnapshotEvents::new(self.rows())
}
pub async fn abandon(self) -> Result<LogicalReplicationStream> {
self.rows().abandon().await
}
#[cfg(test)]
fn for_testing(
stream: LogicalReplicationStream,
tables: Vec<SnapshotTable>,
consistent_point: Lsn,
) -> Self {
Self {
stream: Some(stream),
reader: Some(PgReplicationConnection::null_for_testing()),
tables,
consistent_point,
cancellation_token: CancellationToken::new(),
}
}
}
impl Drop for Snapshot {
fn drop(&mut self) {
if let Some(stream) = self.stream.as_mut() {
stream.discard_replication_slot();
}
}
}
impl LogicalReplicationStream {
pub async fn snapshot(self) -> Result<SnapshotOutcome> {
let reader_conninfo = plan::derive_reader_conninfo(self.conninfo());
self.snapshot_with_reader(&reader_conninfo).await
}
async fn snapshot_with_reader(mut self, reader_conninfo: &str) -> Result<SnapshotOutcome> {
validate_snapshot_config(self.config())?;
self.require_exported_snapshot();
self.ensure_replication_slot().await?;
let (Some(snapshot_name), Some(consistent_point)) = (
self.exported_snapshot_name().map(|s| s.to_string()),
self.consistent_point(),
) else {
tracing::info!("No exported snapshot available; the replication slot already existed");
return Ok(SnapshotOutcome::Unavailable(self));
};
let publications = plan::split_publication_names(&self.config().publication_name);
let server_version = self.server_version();
let prepared = PgReplicationConnection::connect(reader_conninfo).and_then(|mut reader| {
let tables =
resolve_tables(&mut reader, &snapshot_name, server_version, &publications)?;
Ok((reader, tables))
});
let (reader, tables) = match prepared {
Ok(prepared) => prepared,
Err(e) => {
self.discard_replication_slot();
return Err(e);
}
};
tracing::info!(
"Initial snapshot ready: {} table(s) at {}",
tables.len(),
consistent_point
);
Ok(SnapshotOutcome::Available(Snapshot {
stream: Some(self),
reader: Some(reader),
tables,
consistent_point,
cancellation_token: CancellationToken::new(),
}))
}
}
fn resolve_tables(
reader: &mut PgReplicationConnection,
snapshot_name: &str,
server_version: i32,
publications: &[String],
) -> Result<Vec<SnapshotTable>> {
reader.exec("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")?;
reader.exec(&format!(
"SET TRANSACTION SNAPSHOT {}",
quote_literal(snapshot_name)?
))?;
let sql = plan::build_catalog_sql(server_version, publications)?;
let result = reader.exec(&sql)?;
let rows = catalog_rows_from_result(&result)?;
let tables = plan::tables_from_catalog_rows(&rows)?;
if tables.is_empty() {
return Err(ReplicationError::config(format!(
"publication(s) {} contain no tables to snapshot",
publications.join(", ")
)));
}
Ok(tables)
}
fn catalog_rows_from_result(result: &crate::connection::PgResult) -> Result<Vec<CatalogRow>> {
let mut rows = Vec::with_capacity(result.ntuples().max(0) as usize);
for index in 0..result.ntuples() {
let get = |col: i32| -> Result<String> {
result.get_value(index, col).ok_or_else(|| {
ReplicationError::protocol(format!(
"catalog query returned NULL in column {col} of row {index}"
))
})
};
rows.push(CatalogRow {
relation_id: parse_catalog_u32(&get(0)?, "oid")?,
namespace: get(1)?,
relation_name: get(2)?,
replica_identity: get(3)?.bytes().next().unwrap_or(b'd'),
relkind: get(4)?.bytes().next().unwrap_or(b'r'),
column_name: get(5)?,
type_id: parse_catalog_u32(&get(6)?, "type oid")?,
type_modifier: get(7)?.parse::<i32>().map_err(|e| {
ReplicationError::protocol(format!("catalog query returned a bad typmod: {e}"))
})?,
row_filter: result.get_value(index, 8).filter(|v| !v.is_empty()),
generated: matches!(
result.get_value(index, 9).as_deref(),
Some("t") | Some("true")
),
});
}
Ok(rows)
}
fn parse_catalog_u32(text: &str, what: &str) -> Result<u32> {
text.parse::<u32>().map_err(|e| {
ReplicationError::protocol(format!("catalog query returned a bad {what}: {e}"))
})
}
fn validate_snapshot_config(config: &crate::stream::ReplicationStreamConfig) -> Result<()> {
if config.slot_type != crate::types::SlotType::Logical {
return Err(ReplicationError::config(
"initial snapshot requires a logical replication slot",
));
}
if config.slot_options.temporary {
return Err(ReplicationError::config(
"initial snapshot cannot be used with a temporary slot: a reconnect \
re-creates the slot at a later consistent point and silently loses \
every change in between",
));
}
if config.publication_name.trim().is_empty() {
return Err(ReplicationError::config(
"initial snapshot requires a publication",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stream::ReplicationStreamConfig;
use crate::types::{ReplicationSlotOptions, SlotType};
fn snapshot_config() -> ReplicationStreamConfig {
ReplicationStreamConfig::builder("slot", "pub")
}
#[test]
fn validate_accepts_a_logical_slot() {
validate_snapshot_config(&snapshot_config()).unwrap();
}
#[test]
fn a_default_config_is_accepted_and_exports_nothing_until_snapshot_runs() {
let config = ReplicationStreamConfig::builder("s", "p");
assert_ne!(config.slot_options.snapshot.as_deref(), Some("export"));
validate_snapshot_config(&config).unwrap();
}
#[test]
fn validate_rejects_a_physical_slot() {
let config = snapshot_config().with_slot_type(SlotType::Physical);
let err = validate_snapshot_config(&config).unwrap_err();
assert!(format!("{err}").contains("logical"), "{err}");
}
#[test]
fn validate_rejects_a_temporary_slot() {
let config = snapshot_config().with_slot_options(ReplicationSlotOptions {
temporary: true,
..Default::default()
});
let err = validate_snapshot_config(&config).unwrap_err();
assert!(format!("{err}").contains("temporary slot"), "{err}");
}
#[test]
fn validate_rejects_an_empty_publication() {
let config = ReplicationStreamConfig::builder("s", " ");
let err = validate_snapshot_config(&config).unwrap_err();
assert!(format!("{err}").contains("publication"), "{err}");
}
#[test]
fn with_slot_options_cannot_suppress_the_export() {
let config =
ReplicationStreamConfig::builder("s", "p").with_slot_options(ReplicationSlotOptions {
two_phase: true,
snapshot: Some("nothing".to_string()),
..Default::default()
});
assert!(config.slot_options.two_phase);
validate_snapshot_config(&config).unwrap();
}
#[cfg(feature = "rustls-tls")]
fn catalog_result(rows: &[&[Option<&str>]]) -> crate::connection::PgResult {
let mut result = crate::connection::PgResult::new();
result.columns = [
"oid",
"schemaname",
"tablename",
"relreplident",
"relkind",
"attname",
"atttypid",
"atttypmod",
"rowfilter",
"generated",
]
.iter()
.map(|c| c.to_string())
.collect();
result.rows = rows
.iter()
.map(|row| {
row.iter()
.map(|v| v.map(|v| v.as_bytes().to_vec()))
.collect()
})
.collect();
result
}
#[cfg(feature = "rustls-tls")]
fn row(attname: &str, rowfilter: Option<&'static str>) -> Vec<Option<&'static str>> {
generated_row(attname, rowfilter, false)
}
#[cfg(feature = "rustls-tls")]
fn generated_row(
attname: &str,
rowfilter: Option<&'static str>,
generated: bool,
) -> Vec<Option<&'static str>> {
vec![
Some("16384"),
Some("public"),
Some("users"),
Some("d"),
Some("r"),
Some(Box::leak(attname.to_string().into_boxed_str())),
Some("23"),
Some("-1"),
rowfilter,
Some(if generated { "t" } else { "f" }),
]
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_are_read_in_column_order() {
let binding = [row("id", None), row("name", None)];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let rows = catalog_rows_from_result(&catalog_result(&refs)).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].relation_id, 16384);
assert_eq!(rows[0].namespace, "public");
assert_eq!(rows[0].relation_name, "users");
assert_eq!(rows[0].replica_identity, b'd');
assert_eq!(rows[0].column_name, "id");
assert_eq!(rows[0].type_id, 23);
assert_eq!(rows[0].type_modifier, -1);
assert!(rows[0].row_filter.is_none());
assert_eq!(rows[1].column_name, "name");
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_carry_a_row_filter() {
let binding = [row("id", Some("(id > 5)"))];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let rows = catalog_rows_from_result(&catalog_result(&refs)).unwrap();
assert_eq!(rows[0].row_filter.as_deref(), Some("(id > 5)"));
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_carry_the_generated_flag() {
let binding = [
generated_row("id", None, false),
generated_row("g", None, true),
];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let rows = catalog_rows_from_result(&catalog_result(&refs)).unwrap();
assert!(!rows[0].generated);
assert!(rows[1].generated);
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_produce_an_explicit_column_list() {
let binding = [row("id", None)];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let rows = catalog_rows_from_result(&catalog_result(&refs)).unwrap();
let tables = plan::tables_from_catalog_rows(&rows).unwrap();
assert!(
tables[0].copy_sql.contains(r#"("id")"#),
"{}",
tables[0].copy_sql
);
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_reject_a_null_in_a_required_column() {
let mut bad = row("id", None);
bad[1] = None; let binding = [bad];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let err = catalog_rows_from_result(&catalog_result(&refs)).unwrap_err();
assert!(format!("{err}").contains("NULL in column 1"), "{err}");
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_reject_a_malformed_oid() {
let mut bad = row("id", None);
bad[0] = Some("not-an-oid");
let binding = [bad];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let err = catalog_rows_from_result(&catalog_result(&refs)).unwrap_err();
assert!(format!("{err}").contains("bad oid"), "{err}");
}
#[cfg(feature = "rustls-tls")]
#[test]
fn catalog_rows_reject_a_malformed_typmod() {
let mut bad = row("id", None);
bad[7] = Some("x");
let binding = [bad];
let refs: Vec<&[Option<&str>]> = binding.iter().map(|r| r.as_slice()).collect();
let err = catalog_rows_from_result(&catalog_result(&refs)).unwrap_err();
assert!(format!("{err}").contains("bad typmod"), "{err}");
}
#[cfg(feature = "rustls-tls")]
#[test]
fn an_empty_catalog_result_yields_no_rows() {
assert!(catalog_rows_from_result(&catalog_result(&[]))
.unwrap()
.is_empty());
}
fn test_table(name: &str) -> SnapshotTable {
use crate::protocol::{ColumnInfo, RelationInfo};
SnapshotTable {
relation: Arc::new(RelationInfo::new(
42,
"public".to_string(),
name.to_string(),
b'd',
vec![ColumnInfo::new(0, "id".to_string(), 23, -1)],
)),
copy_sql: format!("COPY \"public\".\"{name}\" (\"id\") TO STDOUT"),
}
}
fn test_snapshot(tables: Vec<SnapshotTable>) -> Snapshot {
Snapshot::for_testing(
crate::stream::tests::create_test_stream(snapshot_config()),
tables,
Lsn::new(0xABCD),
)
}
fn block_on<F: core::future::Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
}
#[test]
fn snapshot_exposes_its_plan() {
let snap = test_snapshot(vec![test_table("a"), test_table("b")]);
assert_eq!(snap.tables().len(), 2);
assert_eq!(snap.tables()[0].qualified_name(), "public.a");
assert_eq!(snap.consistent_point(), Lsn::new(0xABCD));
assert_eq!(
snap.stream().current_lsn(),
0,
"a parked stream has not started, so it has no position yet"
);
}
#[test]
fn retain_tables_filters_the_plan() {
let mut snap = test_snapshot(vec![test_table("keep"), test_table("drop")]);
snap.retain_tables(|t| t.relation.relation_name.as_ref() == "keep");
assert_eq!(snap.tables().len(), 1);
assert_eq!(snap.tables()[0].qualified_name(), "public.keep");
}
#[test]
fn with_cancellation_is_carried_into_the_row_stream() {
let token = CancellationToken::new();
let mut rows = test_snapshot(vec![test_table("a")])
.with_cancellation(token.clone())
.rows();
token.cancel();
let err = match block_on(rows.next_row()) {
Ok(_) => panic!("a cancelled snapshot must not start a COPY"),
Err(e) => e,
};
assert!(err.is_cancelled(), "{err:?}");
}
#[test]
fn snapshot_exposes_the_consistent_point_before_consumption() {
assert_eq!(
test_snapshot(vec![test_table("a")]).consistent_point(),
Lsn::new(0xABCD)
);
}
#[test]
fn abandon_returns_the_stream() {
block_on(test_snapshot(vec![test_table("a")]).abandon()).unwrap();
}
#[test]
fn outcome_available_reports_and_skips() {
let outcome = SnapshotOutcome::Available(test_snapshot(vec![test_table("a")]));
assert!(outcome.is_available());
block_on(outcome.skip()).unwrap();
}
#[test]
fn outcome_unavailable_reports_and_skips() {
let outcome = SnapshotOutcome::Unavailable(crate::stream::tests::create_test_stream(
snapshot_config(),
));
assert!(!outcome.is_available());
block_on(outcome.skip()).unwrap();
}
#[test]
fn snapshot_rejects_an_invalid_config_before_touching_the_connection() {
let stream = crate::stream::tests::create_test_stream(
snapshot_config().with_slot_type(SlotType::Physical),
);
let err = match block_on(stream.snapshot()) {
Ok(_) => panic!("a physical slot cannot produce a snapshot"),
Err(e) => e,
};
assert!(matches!(err, ReplicationError::Config(_)), "{err:?}");
assert!(err.is_permanent(), "{err:?}");
}
#[cfg(feature = "rustls-tls")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_valid_config_fails_at_the_connection_rather_than_at_validation() {
let stream = crate::stream::tests::create_test_stream(snapshot_config());
let err = match stream.snapshot().await {
Ok(_) => panic!("a null connection cannot create a replication slot"),
Err(e) => e,
};
assert!(
!matches!(err, ReplicationError::Config(_)),
"a valid config must reach CREATE_REPLICATION_SLOT: {err:?}"
);
}
#[cfg(feature = "rustls-tls")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_tables_blames_the_reader_not_the_publication() {
let mut reader = PgReplicationConnection::null_for_testing();
let err = resolve_tables(
&mut reader,
"00000003-0000001B-1",
crate::sql_builder::PG15,
&["p".to_string()],
)
.unwrap_err();
assert!(!matches!(err, ReplicationError::Config(_)), "{err:?}");
}
#[test]
fn events_are_built_over_the_same_cancellable_row_stream() {
let token = CancellationToken::new();
let mut events = test_snapshot(vec![test_table("a")])
.with_cancellation(token.clone())
.events();
token.cancel();
let err = match block_on(events.next_event()) {
Ok(_) => panic!("a cancelled snapshot must not start a COPY"),
Err(e) => e,
};
assert!(err.is_cancelled(), "{err:?}");
}
#[test]
fn parse_catalog_u32_rejects_garbage() {
assert!(parse_catalog_u32("not-a-number", "oid").is_err());
assert_eq!(parse_catalog_u32("16384", "oid").unwrap(), 16384);
}
}