1#[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 {
447 use super::{
448 build_replication_config, source_config_digest, validate_replication_slot,
449 validate_server_version_num,
450 };
451 use crate::postgres::cdc::{PostgresCdcConfig, SslMode};
452
453 #[test]
454 fn replication_config_disables_tls() {
455 let mut config = PostgresCdcConfig::default();
456 config.ssl_mode = SslMode::Disable;
457 let replication = build_replication_config(&config);
458 assert_eq!(replication.tls.mode, pgwire_replication::SslMode::Disable);
459 }
460
461 #[test]
462 fn replication_config_maps_verified_tls_and_custom_ca() {
463 let mut config = PostgresCdcConfig::default();
464 config.ssl_mode = SslMode::VerifyFull;
465 config.ssl_ca_cert_path = Some("/certs/ca.pem".into());
466
467 let replication = build_replication_config(&config);
468 assert_eq!(
469 replication.tls.mode,
470 pgwire_replication::SslMode::VerifyFull
471 );
472 assert_eq!(replication.tls.ca_pem_path, Some("/certs/ca.pem".into()));
473 assert_eq!(
474 replication.status_interval,
475 std::time::Duration::from_secs(1)
476 );
477 assert_eq!(
478 replication.idle_wakeup_interval,
479 std::time::Duration::from_secs(1)
480 );
481 assert_eq!(replication.max_message_bytes, config.raw_wal_bytes());
482 assert_eq!(replication.max_in_flight_bytes, config.raw_wal_bytes());
483 }
484
485 #[test]
486 fn replication_config_maps_connection_identity() {
487 let mut config = PostgresCdcConfig::new("pg.example.com", "mydb", "my_slot", "my_pub");
488 config.ssl_mode = SslMode::Disable;
489 config.port = 5433;
490 config.username = "replicator".to_string();
491 config.password = Some("secret".to_string());
492
493 let replication = build_replication_config(&config);
494 assert_eq!(replication.host, "pg.example.com");
495 assert_eq!(replication.port, 5433);
496 assert_eq!(replication.user, "replicator");
497 assert_eq!(replication.password, "secret");
498 assert_eq!(replication.database, "mydb");
499 assert_eq!(replication.slot, "my_slot");
500 assert_eq!(replication.publication, "my_pub");
501 }
502
503 #[test]
504 fn existing_slot_must_match_the_durable_logical_identity() {
505 validate_replication_slot(
506 "slot",
507 "pgoutput",
508 "app",
509 Some("pgoutput"),
510 "logical",
511 Some("app"),
512 false,
513 None,
514 )
515 .unwrap();
516
517 for error in [
518 validate_replication_slot(
519 "slot",
520 "pgoutput",
521 "app",
522 Some("test_decoding"),
523 "logical",
524 Some("app"),
525 false,
526 None,
527 )
528 .unwrap_err(),
529 validate_replication_slot(
530 "slot",
531 "pgoutput",
532 "app",
533 Some("pgoutput"),
534 "logical",
535 Some("other"),
536 false,
537 None,
538 )
539 .unwrap_err(),
540 validate_replication_slot(
541 "slot",
542 "pgoutput",
543 "app",
544 Some("pgoutput"),
545 "logical",
546 Some("app"),
547 true,
548 None,
549 )
550 .unwrap_err(),
551 validate_replication_slot(
552 "slot",
553 "pgoutput",
554 "app",
555 Some("pgoutput"),
556 "logical",
557 Some("app"),
558 false,
559 Some("wal_removed"),
560 )
561 .unwrap_err(),
562 ] {
563 assert!(error.to_string().contains("slot"));
564 }
565 }
566
567 #[test]
568 fn source_config_digest_is_canonical_but_semantic() {
569 let mut first = PostgresCdcConfig::default();
570 first.table_include = vec!["public.b".into(), "public.a".into(), "public.a".into()];
571 first.table_exclude = vec!["public.audit".into()];
572
573 let mut reordered = first.clone();
574 reordered.table_include = vec!["public.a".into(), "public.b".into()];
575 reordered.host = "replacement-primary".into();
576 reordered.max_buffered_bytes = 64 * 1024 * 1024;
577 assert_eq!(
578 source_config_digest(&first),
579 source_config_digest(&reordered),
580 "endpoint, capacity, order, and duplicates do not change filtering semantics"
581 );
582
583 reordered.table_exclude.push("public.private".into());
584 assert_ne!(
585 source_config_digest(&first),
586 source_config_digest(&reordered)
587 );
588 }
589
590 #[test]
591 fn server_version_is_admitted_before_pg17_slot_columns_are_used() {
592 let error = validate_server_version_num(160_012).unwrap_err();
593 assert!(error.to_string().contains("PostgreSQL 17"), "{error}");
594 validate_server_version_num(170_000).unwrap();
595 validate_server_version_num(180_001).unwrap();
596 }
597}