1mod catalog;
4pub(super) mod slots;
5
6pub(super) use catalog::{
7 in_recovery, inspect_capture_table, layout_fingerprint, layout_fingerprint_sql,
8 primary_key_index, unreadable_columns, CaptureTable, IndexKeyColumn,
9};
10
11use crate::connector::ConnectorTaskGuard;
12use crate::error::ConnectorError;
13use sha2::{Digest, Sha256};
14use slots::{SlotAttributes, SlotFacts, SlotHolder};
15
16pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
17
18const MINIMUM_SERVER_VERSION_NUM: u32 = 170_000;
19
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub(super) struct PostgresCheckpointBinding {
24 pub system_identifier: u64,
25 pub timeline_id: u32,
26 pub database_oid: u32,
27 pub publication_oid: u32,
28 pub publication_definition_sha256: String,
29 pub source_config_sha256: String,
30}
31
32#[derive(Debug, Clone)]
34pub(super) struct InspectedSource {
35 pub system_identifier: u64,
36 pub timeline_id: u32,
37 pub database_oid: u32,
38 pub publication_oid: u32,
39 pub publication_definition_sha256: String,
40 pub slot: Option<SlotFacts>,
41}
42
43impl InspectedSource {
44 pub(super) fn binding(
46 &self,
47 config: &super::config::PostgresCdcConfig,
48 ) -> PostgresCheckpointBinding {
49 PostgresCheckpointBinding {
50 system_identifier: self.system_identifier,
51 timeline_id: self.timeline_id,
52 database_oid: self.database_oid,
53 publication_oid: self.publication_oid,
54 publication_definition_sha256: self.publication_definition_sha256.clone(),
55 source_config_sha256: source_config_digest(config),
56 }
57 }
58}
59
60fn digest_field(digest: &mut Sha256, value: &[u8]) {
61 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
62 digest.update(value);
63}
64
65#[must_use]
68pub(super) fn source_config_digest(config: &super::config::PostgresCdcConfig) -> String {
69 let mut digest = Sha256::new();
70 digest.update(b"laminardb-postgres-cdc-source-v2\0");
71 digest_field(&mut digest, b"pgoutput");
72 digest_field(&mut digest, b"proto_version=1");
73 digest_field(&mut digest, b"messages=false");
74 digest_field(&mut digest, super::typed_rows::SESSION_OPTIONS.as_bytes());
75 digest_field(&mut digest, config.table.schema.as_bytes());
76 digest_field(&mut digest, config.table.name.as_bytes());
77 digest_field(&mut digest, config.output_mode.to_string().as_bytes());
78 format!("{:x}", digest.finalize())
79}
80
81pub(super) struct ControlConnection {
83 client: tokio_postgres::Client,
84 handle: Option<tokio::task::JoinHandle<()>>,
85}
86
87impl ControlConnection {
88 #[must_use]
89 pub(super) fn client(&self) -> &tokio_postgres::Client {
90 &self.client
91 }
92
93 pub(super) async fn close(mut self) {
94 if let Some(handle) = self.handle.take() {
95 handle.abort();
96 let _ = handle.await;
97 }
98 }
99}
100
101impl Drop for ControlConnection {
102 fn drop(&mut self) {
103 if let Some(handle) = self.handle.take() {
104 handle.abort();
105 }
106 }
107}
108
109pub(super) async fn connect(
115 config: &super::config::PostgresCdcConfig,
116 application_name: &str,
117 driver_guard: ConnectorTaskGuard,
118) -> Result<ControlConnection, ConnectorError> {
119 use crate::postgres::SslMode;
120
121 let pg_config = config.control_connection_config(application_name)?;
122 match config.ssl_mode {
123 SslMode::Disable => {
124 let (client, connection) =
125 tokio::time::timeout(CONNECT_TIMEOUT, pg_config.connect(tokio_postgres::NoTls))
126 .await
127 .map_err(|_| {
128 ConnectorError::ConnectionFailed(
129 "PostgreSQL connect timed out after 10 seconds".into(),
130 )
131 })?
132 .map_err(|error| {
133 ConnectorError::ConnectionFailed(format!("PostgreSQL connect: {error}"))
134 })?;
135 let handle = tokio::spawn(async move {
136 let _driver_guard = driver_guard;
137 if let Err(error) = connection.await {
138 tracing::error!(%error, "PostgreSQL control-plane connection error");
139 }
140 });
141 Ok(ControlConnection {
142 client,
143 handle: Some(handle),
144 })
145 }
146 SslMode::VerifyFull => {
147 let tls = crate::postgres::make_rustls_connector(config.ssl_ca_cert_path.as_deref())?;
148 let (client, connection) =
149 tokio::time::timeout(CONNECT_TIMEOUT, pg_config.connect(tls))
150 .await
151 .map_err(|_| {
152 ConnectorError::ConnectionFailed(
153 "PostgreSQL TLS connect timed out after 10 seconds".into(),
154 )
155 })?
156 .map_err(|error| {
157 ConnectorError::ConnectionFailed(format!("PostgreSQL TLS connect: {error}"))
158 })?;
159 let handle = tokio::spawn(async move {
160 let _driver_guard = driver_guard;
161 if let Err(error) = connection.await {
162 tracing::error!(%error, "PostgreSQL control-plane TLS connection error");
163 }
164 });
165 Ok(ControlConnection {
166 client,
167 handle: Some(handle),
168 })
169 }
170 }
171}
172
173pub(super) async fn inspect_source(
181 client: &tokio_postgres::Client,
182 config: &super::config::PostgresCdcConfig,
183 slot: Option<&str>,
184) -> Result<InspectedSource, ConnectorError> {
185 let (system_identifier, timeline_id) = read_system_identity(client).await?;
186
187 let row = tokio::time::timeout(
191 CONNECT_TIMEOUT,
192 client.query_one(
193 "WITH publication_identity AS ( \
194 SELECT p.oid::text AS publication_oid, p.pubtruncate, \
195 jsonb_build_object( \
196 'properties', to_jsonb(p) - ARRAY['oid', 'pubname', 'pubowner']::text[], \
197 'tables', COALESCE( \
198 (SELECT jsonb_agg( \
199 jsonb_build_array( \
200 c.oid::text, pt.schemaname, pt.tablename, \
201 pt.attnames, pt.rowfilter \
202 ) \
203 ORDER BY pt.schemaname, pt.tablename, c.oid \
204 ) \
205 FROM pg_catalog.pg_publication_tables AS pt \
206 LEFT JOIN pg_catalog.pg_namespace AS n \
207 ON n.nspname = pt.schemaname \
208 LEFT JOIN pg_catalog.pg_class AS c \
209 ON c.relnamespace = n.oid AND c.relname = pt.tablename \
210 WHERE pt.pubname = p.pubname), \
211 '[]'::jsonb \
212 ) \
213 )::text AS definition \
214 FROM pg_catalog.pg_publication AS p \
215 WHERE p.pubname = $2 \
216 ) \
217 SELECT db.oid::text, publication_identity.publication_oid, \
218 publication_identity.definition, publication_identity.pubtruncate, \
219 s.slot_name IS NOT NULL, s.confirmed_flush_lsn::text, s.slot_type, s.plugin, \
220 s.database::text, s.datoid::text, s.temporary, s.two_phase, s.failover, \
221 s.synced, s.invalidation_reason, s.conflicting, s.wal_status, \
222 s.active_pid, a.application_name, a.client_addr::text \
223 FROM pg_catalog.pg_database AS db \
224 LEFT JOIN pg_catalog.pg_replication_slots AS s ON s.slot_name = $1 \
225 LEFT JOIN pg_catalog.pg_stat_activity AS a ON a.pid = s.active_pid \
226 LEFT JOIN publication_identity ON TRUE \
227 WHERE db.datname = current_database()",
228 &[&slot, &config.publication],
229 ),
230 )
231 .await
232 .map_err(|_| {
233 ConnectorError::ConnectionFailed("query replication slot timed out after 10 seconds".into())
234 })?
235 .map_err(|error| {
236 ConnectorError::ConnectionFailed(format!(
237 "query PostgreSQL replication identity: {error}"
238 ))
239 })?;
240
241 let (database_oid, publication_oid, publication_definition_sha256) =
242 read_publication_identity(&row, &config.publication)?;
243 let slot = match slot {
244 Some(name) if row.get::<_, bool>(4) => {
245 Some(read_slot(&row, name, &config.database, database_oid)?)
246 }
247 _ => None,
248 };
249 Ok(InspectedSource {
250 system_identifier,
251 timeline_id,
252 database_oid,
253 publication_oid,
254 publication_definition_sha256,
255 slot,
256 })
257}
258
259fn read_slot(
261 row: &tokio_postgres::Row,
262 name: &str,
263 database: &str,
264 database_oid: u32,
265) -> Result<SlotFacts, ConnectorError> {
266 let confirmed: Option<&str> = row.get(5);
267 let confirmed_flush_lsn = confirmed
268 .map(|value| {
269 value.parse().map_err(|error| {
270 ConnectorError::ReadError(format!("invalid confirmed_flush_lsn: {error}"))
271 })
272 })
273 .transpose()?;
274 let datoid: Option<&str> = row.get(9);
275 let attributes = SlotAttributes {
276 slot_type: row.get(6),
277 plugin: row.get(7),
278 database: row.get(8),
279 database_oid: datoid
280 .map(|oid| parse_decimal_identity::<u32>(oid, "slot database OID"))
281 .transpose()?,
282 temporary: row.get::<_, Option<bool>>(10).unwrap_or(false),
283 two_phase: row.get::<_, Option<bool>>(11).unwrap_or(false),
284 failover: row.get::<_, Option<bool>>(12).unwrap_or(false),
285 synced: row.get::<_, Option<bool>>(13).unwrap_or(false),
286 invalidation_reason: row.get(14),
287 conflicting: row.get::<_, Option<bool>>(15).unwrap_or(false),
288 wal_status: row.get(16),
289 };
290 let holder = row.get::<_, Option<i32>>(17).map(|pid| SlotHolder {
291 pid,
292 application_name: row.get::<_, Option<String>>(18).unwrap_or_default(),
293 client_addr: row.get(19),
294 });
295 if attributes.wal_status.as_deref() == Some("unreserved") {
296 tracing::warn!(
297 slot = name,
298 "PostgreSQL replication slot no longer reserves all the WAL it needs; the next \
299 checkpoint removes some of it unless the slot catches up (wal_status = unreserved, \
300 max_slot_wal_keep_size)"
301 );
302 }
303 Ok(SlotFacts {
304 confirmed_flush_lsn,
305 holder,
306 unusable: attributes.problem(database, database_oid),
307 })
308}
309
310fn read_publication_identity(
311 row: &tokio_postgres::Row,
312 publication: &str,
313) -> Result<(u32, u32, String), ConnectorError> {
314 let database_oid = parse_decimal_identity::<u32>(row.get(0), "database OID")?;
315 let publication_oid_text: Option<&str> = row.get(1);
316 let publication_oid = publication_oid_text
317 .ok_or_else(|| {
318 ConnectorError::ConfigurationError(format!(
319 "PostgreSQL publication '{publication}' does not exist"
320 ))
321 })
322 .and_then(|value| parse_decimal_identity::<u32>(value, "publication OID"))?;
323 let publication_definition: Option<&str> = row.get(2);
324 let publication_definition = publication_definition.ok_or_else(|| {
325 ConnectorError::ConfigurationError(format!(
326 "PostgreSQL publication '{publication}' has no readable definition"
327 ))
328 })?;
329 let publication_truncates: Option<bool> = row.get(3);
330 if publication_truncates != Some(true) {
331 return Err(ConnectorError::ConfigurationError(format!(
332 "PostgreSQL publication '{publication}' must publish TRUNCATE so a source truncation \
333 stops the pipeline instead of silently diverging the target; use the default \
334 publish='insert, update, delete, truncate'"
335 )));
336 }
337 let mut publication_digest = Sha256::new();
338 publication_digest.update(b"laminardb-postgres-publication-v1\0");
339 digest_field(&mut publication_digest, publication_definition.as_bytes());
340 Ok((
341 database_oid,
342 publication_oid,
343 format!("{:x}", publication_digest.finalize()),
344 ))
345}
346
347async fn read_system_identity(
348 client: &tokio_postgres::Client,
349) -> Result<(u64, u32), ConnectorError> {
350 let version_row = tokio::time::timeout(
351 CONNECT_TIMEOUT,
352 client.query_one("SELECT current_setting('server_version_num')", &[]),
353 )
354 .await
355 .map_err(|_| {
356 ConnectorError::ConnectionFailed(
357 "query PostgreSQL server version timed out after 10 seconds".into(),
358 )
359 })?
360 .map_err(|error| {
361 ConnectorError::ConnectionFailed(format!("query PostgreSQL server version: {error}"))
362 })?;
363 let version_text: &str = version_row.try_get(0).map_err(|error| {
364 ConnectorError::ReadError(format!("read PostgreSQL server version: {error}"))
365 })?;
366 let version_num = version_text.parse::<u32>().map_err(|error| {
367 ConnectorError::ReadError(format!(
368 "invalid PostgreSQL server_version_num '{version_text}': {error}"
369 ))
370 })?;
371 validate_server_version_num(version_num)?;
372
373 let control_row = tokio::time::timeout(
374 CONNECT_TIMEOUT,
375 client.query_one(
376 "SELECT control_system.system_identifier::text, control_checkpoint.timeline_id::text \
377 FROM pg_catalog.pg_control_system() AS control_system \
378 CROSS JOIN pg_catalog.pg_control_checkpoint() AS control_checkpoint",
379 &[],
380 ),
381 )
382 .await
383 .map_err(|_| {
384 ConnectorError::ConnectionFailed(
385 "query PostgreSQL system identifier and timeline timed out after 10 seconds".into(),
386 )
387 })?
388 .map_err(|error| map_control_system_query_error(&error))?;
389 let system_identifier = parse_decimal_identity::<u64>(
390 control_row.try_get(0).map_err(|error| {
391 ConnectorError::ReadError(format!("read PostgreSQL system identifier: {error}"))
392 })?,
393 "system identifier",
394 )?;
395 let timeline_id = parse_decimal_identity::<u32>(
396 control_row.try_get(1).map_err(|error| {
397 ConnectorError::ReadError(format!("read PostgreSQL timeline: {error}"))
398 })?,
399 "timeline ID",
400 )?;
401
402 Ok((system_identifier, timeline_id))
403}
404
405pub(super) async fn slot_wal_status(
411 client: &tokio_postgres::Client,
412 slot_name: &str,
413) -> Result<(String, Option<i64>), ConnectorError> {
414 let row = tokio::time::timeout(
415 CONNECT_TIMEOUT,
416 client.query_opt(
417 "SELECT wal_status, safe_wal_size FROM pg_catalog.pg_replication_slots \
418 WHERE slot_name = $1",
419 &[&slot_name],
420 ),
421 )
422 .await
423 .map_err(|_| {
424 ConnectorError::ConnectionFailed("query slot WAL status timed out after 10 seconds".into())
425 })?
426 .map_err(|error| ConnectorError::ConnectionFailed(format!("query slot WAL status: {error}")))?
427 .ok_or_else(|| {
428 ConnectorError::ReadError(format!(
429 "PostgreSQL replication slot '{slot_name}' disappeared"
430 ))
431 })?;
432 Ok((
433 row.get::<_, Option<String>>(0).unwrap_or_default(),
434 row.get(1),
435 ))
436}
437
438fn validate_server_version_num(version_num: u32) -> Result<(), ConnectorError> {
439 if version_num < MINIMUM_SERVER_VERSION_NUM {
440 return Err(ConnectorError::ConfigurationError(format!(
441 "PostgreSQL CDC requires PostgreSQL 17 or newer; server_version_num is {version_num}"
442 )));
443 }
444 Ok(())
445}
446
447pub(super) fn is_connection_failure(sqlstate: Option<&str>) -> bool {
450 sqlstate.is_none_or(|code| code.starts_with("08") || code.starts_with("57P0"))
451}
452
453fn parse_decimal_identity<T>(value: &str, label: &str) -> Result<T, ConnectorError>
454where
455 T: std::str::FromStr,
456 T::Err: std::fmt::Display,
457{
458 value.parse::<T>().map_err(|error| {
459 ConnectorError::ReadError(format!("invalid PostgreSQL {label} '{value}': {error}"))
460 })
461}
462
463fn map_control_system_query_error(error: &tokio_postgres::Error) -> ConnectorError {
464 if error.code() == Some(&tokio_postgres::error::SqlState::INSUFFICIENT_PRIVILEGE) {
465 return ConnectorError::ConfigurationError(
466 "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"
467 .into(),
468 );
469 }
470 ConnectorError::ConnectionFailed(format!(
471 "query PostgreSQL system identifier and timeline: {error}"
472 ))
473}
474
475#[must_use]
477pub(super) fn build_replication_config(
478 config: &super::config::PostgresCdcConfig,
479 slot: &str,
480 application_name: &str,
481) -> pgwire_replication::ReplicationConfig {
482 pgwire_replication::ReplicationConfig {
483 host: config.host.clone(),
484 port: config.port,
485 user: config.username.clone(),
486 password: config.password.clone().unwrap_or_default(),
487 database: config.database.clone(),
488 tls: match config.ssl_mode {
489 crate::postgres::SslMode::Disable => pgwire_replication::TlsConfig::disabled(),
490 crate::postgres::SslMode::VerifyFull => {
491 pgwire_replication::TlsConfig::verify_full(config.ssl_ca_cert_path.clone())
492 }
493 },
494 slot: slot.to_string(),
495 publication: config.publication.clone(),
496 start_lsn: pgwire_replication::Lsn::ZERO,
499 expected_recovery_identity: None,
500 stop_at_lsn: None,
501 status_interval: std::time::Duration::from_secs(1),
502 idle_wakeup_interval: std::time::Duration::from_secs(1),
503 buffer_events: 8192,
504 max_message_bytes: config.raw_wal_bytes(),
505 session_options: Some(super::typed_rows::SESSION_OPTIONS.into()),
506 application_name: application_name.to_string(),
507 max_in_flight_bytes: config.raw_wal_bytes(),
508 }
509}
510
511#[cfg(test)]
512mod tests;