laminar_connectors/postgres/cdc/postgres_io/
mod.rs1#[cfg(not(test))]
4use super::lsn::Lsn;
5#[cfg(not(test))]
6use crate::connector::ConnectorTaskGuard;
7use crate::error::ConnectorError;
8use sha2::{Digest, Sha256};
9
10pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
11
12const MINIMUM_SERVER_VERSION_NUM: u32 = 170_000;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub(super) struct PostgresCheckpointBinding {
17 pub system_identifier: u64,
18 pub timeline_id: u32,
19 pub database_oid: u32,
20 pub publication_oid: u32,
21 pub publication_definition_sha256: String,
22 pub source_config_sha256: String,
23 pub slot_plugin: String,
24 pub slot_two_phase: bool,
25 pub slot_failover: bool,
26}
27
28#[cfg(not(test))]
30pub(super) struct InspectedReplicationSlot {
31 pub confirmed_flush_lsn: Option<Lsn>,
32 pub binding: PostgresCheckpointBinding,
33}
34
35fn digest_field(digest: &mut Sha256, value: &[u8]) {
36 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
37 digest.update(value);
38}
39
40#[must_use]
43pub(super) fn source_config_digest(config: &super::config::PostgresCdcConfig) -> String {
44 let mut digest = Sha256::new();
45 digest.update(b"laminardb-postgres-cdc-source-v1\0");
46 digest_field(&mut digest, b"pgoutput");
47 digest_field(&mut digest, b"proto_version=1");
48 digest_field(&mut digest, b"messages=false");
49
50 for tables in [&config.table_include, &config.table_exclude] {
51 let mut canonical: Vec<&str> = tables.iter().map(String::as_str).collect();
52 canonical.sort_unstable();
53 canonical.dedup();
54 digest.update(
55 u64::try_from(canonical.len())
56 .unwrap_or(u64::MAX)
57 .to_be_bytes(),
58 );
59 for table in canonical {
60 digest_field(&mut digest, table.as_bytes());
61 }
62 }
63
64 format!("{:x}", digest.finalize())
65}
66
67#[cfg(not(test))]
69pub(super) struct ControlConnection {
70 client: tokio_postgres::Client,
71 handle: Option<tokio::task::JoinHandle<()>>,
72}
73
74#[cfg(not(test))]
75impl ControlConnection {
76 #[must_use]
77 pub(super) fn client(&self) -> &tokio_postgres::Client {
78 &self.client
79 }
80
81 pub(super) async fn close(mut self) {
82 if let Some(handle) = self.handle.take() {
83 handle.abort();
84 let _ = handle.await;
85 }
86 }
87}
88
89#[cfg(not(test))]
90impl Drop for ControlConnection {
91 fn drop(&mut self) {
92 if let Some(handle) = self.handle.take() {
93 handle.abort();
94 }
95 }
96}
97
98#[cfg(not(test))]
104pub(super) async fn connect(
105 config: &super::config::PostgresCdcConfig,
106 driver_guard: ConnectorTaskGuard,
107) -> Result<ControlConnection, ConnectorError> {
108 use crate::postgres::SslMode;
109
110 let pg_config = config.control_connection_config()?;
111 match config.ssl_mode {
112 SslMode::Disable => {
113 let (client, connection) =
114 tokio::time::timeout(CONNECT_TIMEOUT, pg_config.connect(tokio_postgres::NoTls))
115 .await
116 .map_err(|_| {
117 ConnectorError::ConnectionFailed(
118 "PostgreSQL connect timed out after 10 seconds".into(),
119 )
120 })?
121 .map_err(|error| {
122 ConnectorError::ConnectionFailed(format!("PostgreSQL connect: {error}"))
123 })?;
124 let handle = tokio::spawn(async move {
125 let _driver_guard = driver_guard;
126 if let Err(error) = connection.await {
127 tracing::error!(%error, "PostgreSQL control-plane connection error");
128 }
129 });
130 Ok(ControlConnection {
131 client,
132 handle: Some(handle),
133 })
134 }
135 SslMode::VerifyFull => {
136 let tls = crate::postgres::make_rustls_connector(config.ssl_ca_cert_path.as_deref())?;
137 let (client, connection) =
138 tokio::time::timeout(CONNECT_TIMEOUT, pg_config.connect(tls))
139 .await
140 .map_err(|_| {
141 ConnectorError::ConnectionFailed(
142 "PostgreSQL TLS connect timed out after 10 seconds".into(),
143 )
144 })?
145 .map_err(|error| {
146 ConnectorError::ConnectionFailed(format!("PostgreSQL TLS connect: {error}"))
147 })?;
148 let handle = tokio::spawn(async move {
149 let _driver_guard = driver_guard;
150 if let Err(error) = connection.await {
151 tracing::error!(%error, "PostgreSQL control-plane TLS connection error");
152 }
153 });
154 Ok(ControlConnection {
155 client,
156 handle: Some(handle),
157 })
158 }
159 }
160}
161
162#[cfg(not(test))]
172pub(super) async fn inspect_replication_slot(
173 client: &tokio_postgres::Client,
174 slot_name: &str,
175 plugin: &str,
176 database: &str,
177 publication: &str,
178 source_config_sha256: String,
179) -> Result<Option<InspectedReplicationSlot>, ConnectorError> {
180 let version_row = tokio::time::timeout(
181 CONNECT_TIMEOUT,
182 client.query_one("SELECT current_setting('server_version_num')", &[]),
183 )
184 .await
185 .map_err(|_| {
186 ConnectorError::ConnectionFailed(
187 "query PostgreSQL server version timed out after 10 seconds".into(),
188 )
189 })?
190 .map_err(|error| {
191 ConnectorError::ConnectionFailed(format!("query PostgreSQL server version: {error}"))
192 })?;
193 let version_text: &str = version_row.get(0);
194 let version_num = version_text.parse::<u32>().map_err(|error| {
195 ConnectorError::ReadError(format!(
196 "invalid PostgreSQL server_version_num '{version_text}': {error}"
197 ))
198 })?;
199 validate_server_version_num(version_num)?;
200
201 let control_row = tokio::time::timeout(
202 CONNECT_TIMEOUT,
203 client.query_one(
204 "SELECT control_system.system_identifier::text, control_checkpoint.timeline_id::text \
205 FROM pg_catalog.pg_control_system() AS control_system \
206 CROSS JOIN pg_catalog.pg_control_checkpoint() AS control_checkpoint",
207 &[],
208 ),
209 )
210 .await
211 .map_err(|_| {
212 ConnectorError::ConnectionFailed(
213 "query PostgreSQL system identifier and timeline timed out after 10 seconds".into(),
214 )
215 })?
216 .map_err(|error| map_control_system_query_error(&error))?;
217 let system_identifier = parse_decimal_identity::<u64>(control_row.get(0), "system identifier")?;
218 let timeline_id = parse_decimal_identity::<u32>(control_row.get(1), "timeline ID")?;
219
220 let row = tokio::time::timeout(
224 CONNECT_TIMEOUT,
225 client.query_opt(
226 "WITH publication_identity AS ( \
227 SELECT p.oid::text AS publication_oid, p.pubtruncate, \
228 jsonb_build_object( \
229 'properties', to_jsonb(p) - ARRAY['oid', 'pubname', 'pubowner']::text[], \
230 'tables', COALESCE( \
231 (SELECT jsonb_agg( \
232 jsonb_build_array( \
233 c.oid::text, pt.schemaname, pt.tablename, \
234 pt.attnames, pt.rowfilter \
235 ) \
236 ORDER BY pt.schemaname, pt.tablename, c.oid \
237 ) \
238 FROM pg_catalog.pg_publication_tables AS pt \
239 LEFT JOIN pg_catalog.pg_namespace AS n \
240 ON n.nspname = pt.schemaname \
241 LEFT JOIN pg_catalog.pg_class AS c \
242 ON c.relnamespace = n.oid AND c.relname = pt.tablename \
243 WHERE pt.pubname = p.pubname), \
244 '[]'::jsonb \
245 ) \
246 )::text AS definition \
247 FROM pg_catalog.pg_publication AS p \
248 WHERE p.pubname = $2 \
249 ) \
250 SELECT s.confirmed_flush_lsn::text, s.plugin, s.slot_type, \
251 s.database::text, s.temporary, s.two_phase, s.failover, \
252 s.invalidation_reason, db.oid::text, publication_identity.publication_oid, \
253 publication_identity.definition, publication_identity.pubtruncate \
254 FROM pg_catalog.pg_replication_slots AS s \
255 CROSS JOIN pg_catalog.pg_database AS db \
256 LEFT JOIN publication_identity ON TRUE \
257 WHERE s.slot_name = $1 AND db.datname = current_database()",
258 &[&slot_name, &publication],
259 ),
260 )
261 .await
262 .map_err(|_| {
263 ConnectorError::ConnectionFailed("query replication slot timed out after 10 seconds".into())
264 })?
265 .map_err(|error| {
266 ConnectorError::ConnectionFailed(format!(
267 "query PostgreSQL replication identity: {error}"
268 ))
269 })?;
270
271 let Some(row) = row else {
272 return Ok(None);
273 };
274 let configured_plugin: Option<&str> = row.get(1);
275 let slot_type: &str = row.get(2);
276 let configured_database: Option<&str> = row.get(3);
277 let temporary: bool = row.get(4);
278 let two_phase: bool = row.get(5);
279 let failover: bool = row.get(6);
280 let invalidation_reason: Option<&str> = row.get(7);
281 validate_replication_slot(
282 slot_name,
283 plugin,
284 database,
285 configured_plugin,
286 slot_type,
287 configured_database,
288 temporary,
289 invalidation_reason,
290 )?;
291 let database_oid = parse_decimal_identity::<u32>(row.get(8), "database OID")?;
292 let publication_oid_text: Option<&str> = row.get(9);
293 let publication_oid = publication_oid_text
294 .ok_or_else(|| {
295 ConnectorError::ConfigurationError(format!(
296 "PostgreSQL publication '{publication}' does not exist"
297 ))
298 })
299 .and_then(|value| parse_decimal_identity::<u32>(value, "publication OID"))?;
300 let publication_definition: Option<&str> = row.get(10);
301 let publication_definition = publication_definition.ok_or_else(|| {
302 ConnectorError::ConfigurationError(format!(
303 "PostgreSQL publication '{publication}' has no readable definition"
304 ))
305 })?;
306 let publication_truncates: Option<bool> = row.get(11);
307 if publication_truncates != Some(false) {
308 return Err(ConnectorError::ConfigurationError(format!(
309 "PostgreSQL publication '{publication}' publishes TRUNCATE, which this CDC source cannot represent; recreate or alter it with publish='insert,update,delete'"
310 )));
311 }
312 let mut publication_digest = Sha256::new();
313 publication_digest.update(b"laminardb-postgres-publication-v1\0");
314 digest_field(&mut publication_digest, publication_definition.as_bytes());
315 tracing::info!(
316 slot = slot_name,
317 two_phase,
318 failover,
319 "using logical replication slot"
320 );
321
322 let lsn: Option<&str> = row.get(0);
323 let confirmed_flush_lsn = lsn
324 .map(|value| {
325 value.parse().map_err(|error| {
326 ConnectorError::ReadError(format!("invalid confirmed_flush_lsn: {error}"))
327 })
328 })
329 .transpose()?;
330 Ok(Some(InspectedReplicationSlot {
331 confirmed_flush_lsn,
332 binding: PostgresCheckpointBinding {
333 system_identifier,
334 timeline_id,
335 database_oid,
336 publication_oid,
337 publication_definition_sha256: format!("{:x}", publication_digest.finalize()),
338 source_config_sha256,
339 slot_plugin: configured_plugin.unwrap_or_default().to_string(),
340 slot_two_phase: two_phase,
341 slot_failover: failover,
342 },
343 }))
344}
345
346fn validate_server_version_num(version_num: u32) -> Result<(), ConnectorError> {
347 if version_num < MINIMUM_SERVER_VERSION_NUM {
348 return Err(ConnectorError::ConfigurationError(format!(
349 "PostgreSQL CDC requires PostgreSQL 17 or newer; server_version_num is {version_num}"
350 )));
351 }
352 Ok(())
353}
354
355#[cfg(not(test))]
356fn parse_decimal_identity<T>(value: &str, label: &str) -> Result<T, ConnectorError>
357where
358 T: std::str::FromStr,
359 T::Err: std::fmt::Display,
360{
361 value.parse::<T>().map_err(|error| {
362 ConnectorError::ReadError(format!("invalid PostgreSQL {label} '{value}': {error}"))
363 })
364}
365
366#[cfg(not(test))]
367fn map_control_system_query_error(error: &tokio_postgres::Error) -> ConnectorError {
368 if error.code() == Some(&tokio_postgres::error::SqlState::INSUFFICIENT_PRIVILEGE) {
369 return ConnectorError::ConfigurationError(
370 "PostgreSQL CDC must call pg_catalog.pg_control_system() and pg_catalog.pg_control_checkpoint() to bind checkpoints to a physical cluster and WAL timeline; grant the replication role pg_monitor or EXECUTE on both functions"
371 .into(),
372 );
373 }
374 ConnectorError::ConnectionFailed(format!(
375 "query PostgreSQL system identifier and timeline: {error}"
376 ))
377}
378
379fn validate_replication_slot(
380 slot_name: &str,
381 expected_plugin: &str,
382 expected_database: &str,
383 configured_plugin: Option<&str>,
384 slot_type: &str,
385 configured_database: Option<&str>,
386 temporary: bool,
387 invalidation_reason: Option<&str>,
388) -> Result<(), ConnectorError> {
389 if slot_type != "logical" || configured_plugin != Some(expected_plugin) {
390 return Err(ConnectorError::ConfigurationError(format!(
391 "PostgreSQL replication slot '{slot_name}' is not a logical {expected_plugin} slot"
392 )));
393 }
394 if configured_database != Some(expected_database) {
395 return Err(ConnectorError::ConfigurationError(format!(
396 "PostgreSQL replication slot '{slot_name}' belongs to database '{}', not configured database '{expected_database}'",
397 configured_database.unwrap_or("<none>")
398 )));
399 }
400 if temporary {
401 return Err(ConnectorError::ConfigurationError(format!(
402 "PostgreSQL replication slot '{slot_name}' is temporary and cannot provide durable recovery"
403 )));
404 }
405 if let Some(reason) = invalidation_reason {
406 return Err(ConnectorError::ReadError(format!(
407 "PostgreSQL replication slot '{slot_name}' is invalidated: {reason}"
408 )));
409 }
410 Ok(())
411}
412
413#[must_use]
415pub(super) fn build_replication_config(
416 config: &super::config::PostgresCdcConfig,
417) -> pgwire_replication::ReplicationConfig {
418 pgwire_replication::ReplicationConfig {
419 host: config.host.clone(),
420 port: config.port,
421 user: config.username.clone(),
422 password: config.password.clone().unwrap_or_default(),
423 database: config.database.clone(),
424 tls: match config.ssl_mode {
425 crate::postgres::SslMode::Disable => pgwire_replication::TlsConfig::disabled(),
426 crate::postgres::SslMode::VerifyFull => {
427 pgwire_replication::TlsConfig::verify_full(config.ssl_ca_cert_path.clone())
428 }
429 },
430 slot: config.slot_name.clone(),
431 publication: config.publication.clone(),
432 start_lsn: pgwire_replication::Lsn::ZERO,
435 expected_recovery_identity: None,
436 stop_at_lsn: None,
437 status_interval: std::time::Duration::from_secs(1),
438 idle_wakeup_interval: std::time::Duration::from_secs(1),
439 buffer_events: 8192,
440 max_message_bytes: config.raw_wal_bytes(),
441 max_in_flight_bytes: config.raw_wal_bytes(),
442 }
443}
444
445#[cfg(test)]
446mod tests;