1use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35use faucet_core::FaucetError;
36use pgwire_replication::{Lsn, ReplicationClient, ReplicationConfig, TlsConfig};
37use sqlx::postgres::PgConnectOptions;
38
39pub use pgwire_replication::ReplicationEvent;
42use sqlx::{Executor, PgConnection};
43use tracing::debug;
44
45pub const POSTGRES_EPOCH_MICROS: i64 = 946_684_800_000_000;
48
49pub struct Client {
58 _private: (),
59}
60
61pub struct Duplex {
64 inner: ReplicationClient,
65}
66
67#[derive(Clone, Debug)]
73pub struct ReplicationParams<'a> {
74 pub connection_url: &'a str,
76 pub slot_name: &'a str,
78 pub publication_name: &'a str,
80 pub proto_version: u32,
82 pub create_slot_if_missing: bool,
84 pub start_lsn: Option<u64>,
87 pub status_update_interval: Duration,
90 pub tcp_keepalive: Duration,
93 pub slot_type: crate::config::SlotType,
95 pub tls: &'a crate::config::CdcTls,
97}
98
99#[cfg_attr(test, derive(Debug))]
102struct PgCoords {
103 host: String,
104 port: u16,
105 user: String,
106 password: String,
107 dbname: String,
108}
109
110fn parse_url(url: &str) -> Result<PgCoords, FaucetError> {
111 let parsed = url::Url::parse(url)
114 .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
115
116 let host = parsed
122 .host_str()
123 .filter(|h| !h.is_empty())
124 .ok_or_else(|| {
125 FaucetError::Config(
126 "postgres-cdc: connection URL is missing a host (expected \
127 postgres://user@host[:port]/dbname)"
128 .to_owned(),
129 )
130 })?
131 .to_owned();
132 let port = parsed.port().unwrap_or(5432);
133 let user = parsed.username().to_owned();
134 if user.is_empty() {
135 return Err(FaucetError::Config(
136 "postgres-cdc: connection URL is missing a user (expected \
137 postgres://user@host[:port]/dbname)"
138 .to_owned(),
139 ));
140 }
141 let password = parsed.password().unwrap_or("").to_owned();
142 let dbname = parsed.path().trim_start_matches('/').to_owned();
143 let dbname = if dbname.is_empty() {
144 "postgres".to_owned()
145 } else {
146 dbname
147 };
148
149 Ok(PgCoords {
150 host,
151 port,
152 user,
153 password,
154 dbname,
155 })
156}
157
158pub async fn connect(params: &ReplicationParams<'_>) -> Result<Client, FaucetError> {
166 let _ = parse_url(params.connection_url)?;
169 Ok(Client { _private: () })
170}
171
172pub async fn ensure_slot(
179 _client: &Client,
180 connection_url: &str,
181 slot_name: &str,
182 create_if_missing: bool,
183 slot_type: crate::config::SlotType,
184 tls: &crate::config::CdcTls,
185) -> Result<(), FaucetError> {
186 use crate::config::SlotType;
187 let opts: PgConnectOptions = connection_url
189 .parse()
190 .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
191 let opts = apply_cdc_tls(opts, tls);
192
193 use sqlx::ConnectOptions as _;
194 let mut conn: PgConnection = opts
195 .connect()
196 .await
197 .map_err(|e| FaucetError::Source(format!("postgres-cdc ensure_slot connect: {e}")))?;
198
199 let row: Option<(String,)> =
201 sqlx::query_as("SELECT slot_name::text FROM pg_replication_slots WHERE slot_name = $1")
202 .bind(slot_name)
203 .fetch_optional(&mut conn)
204 .await
205 .map_err(|e| FaucetError::Source(format!("postgres-cdc slot lookup: {e}")))?;
206
207 if row.is_some() {
208 debug!("postgres-cdc: replication slot '{slot_name}' already exists");
209 return Ok(());
210 }
211
212 if !create_if_missing {
213 return Err(FaucetError::Source(format!(
214 "postgres-cdc: replication slot '{slot_name}' does not exist \
215 and create_slot_if_missing = false"
216 )));
217 }
218
219 let temporary = matches!(slot_type, SlotType::Temporary);
226 let sql = format!(
227 "SELECT pg_create_logical_replication_slot({}, 'pgoutput', {})",
228 quote_literal(slot_name),
229 temporary
230 );
231 conn.execute(sql.as_str())
232 .await
233 .map_err(|e| FaucetError::Source(format!("postgres-cdc create slot: {e}")))?;
234
235 if temporary {
236 debug!("postgres-cdc: created temporary replication slot '{slot_name}'");
237 } else {
238 tracing::warn!(
241 "postgres-cdc: created PERMANENT replication slot '{slot_name}' — it will retain \
242 WAL on the server until consumed or explicitly dropped (drop_slot). Use \
243 slot_type=temporary for ephemeral runs."
244 );
245 }
246 Ok(())
247}
248
249pub async fn drop_slot(
253 connection_url: &str,
254 slot_name: &str,
255 tls: &crate::config::CdcTls,
256) -> Result<(), FaucetError> {
257 let opts: PgConnectOptions = connection_url
258 .parse()
259 .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
260 let opts = apply_cdc_tls(opts, tls);
261 use sqlx::ConnectOptions as _;
262 let mut conn: PgConnection = opts
263 .connect()
264 .await
265 .map_err(|e| FaucetError::Source(format!("postgres-cdc drop_slot connect: {e}")))?;
266
267 let exists: Option<(String,)> =
268 sqlx::query_as("SELECT slot_name::text FROM pg_replication_slots WHERE slot_name = $1")
269 .bind(slot_name)
270 .fetch_optional(&mut conn)
271 .await
272 .map_err(|e| FaucetError::Source(format!("postgres-cdc slot lookup: {e}")))?;
273 if exists.is_none() {
274 debug!("postgres-cdc: replication slot '{slot_name}' already absent; drop is a no-op");
275 return Ok(());
276 }
277
278 sqlx::query("SELECT pg_drop_replication_slot($1)")
279 .bind(slot_name)
280 .execute(&mut conn)
281 .await
282 .map_err(|e| FaucetError::Source(format!("postgres-cdc drop slot: {e}")))?;
283 debug!("postgres-cdc: dropped replication slot '{slot_name}'");
284 Ok(())
285}
286
287fn apply_cdc_tls(opts: PgConnectOptions, tls: &crate::config::CdcTls) -> PgConnectOptions {
296 use crate::config::CdcTls;
297 use sqlx::postgres::PgSslMode;
298 match tls {
299 CdcTls::Disable => opts.ssl_mode(PgSslMode::Disable),
300 CdcTls::Require => opts.ssl_mode(PgSslMode::Require),
301 CdcTls::VerifyCa { ca_path } => {
302 let o = opts.ssl_mode(PgSslMode::VerifyCa);
303 match ca_path {
304 Some(p) => o.ssl_root_cert(p),
305 None => o,
306 }
307 }
308 CdcTls::VerifyFull { ca_path } => {
309 let o = opts.ssl_mode(PgSslMode::VerifyFull);
310 match ca_path {
311 Some(p) => o.ssl_root_cert(p),
312 None => o,
313 }
314 }
315 }
316}
317
318fn tls_config(tls: &crate::config::CdcTls) -> TlsConfig {
321 use crate::config::CdcTls;
322 use std::path::PathBuf;
323 match tls {
324 CdcTls::Disable => TlsConfig::disabled(),
325 CdcTls::Require => TlsConfig::require(),
326 CdcTls::VerifyCa { ca_path } => TlsConfig::verify_ca(ca_path.clone().map(PathBuf::from)),
327 CdcTls::VerifyFull { ca_path } => {
328 TlsConfig::verify_full(ca_path.clone().map(PathBuf::from))
329 }
330 }
331}
332
333pub async fn advance_slot(
349 connection_url: &str,
350 slot_name: &str,
351 lsn: u64,
352 tls: &crate::config::CdcTls,
353) -> Result<(), FaucetError> {
354 if lsn == 0 {
355 return Ok(());
356 }
357 let opts: PgConnectOptions = connection_url
358 .parse()
359 .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
360 let opts = apply_cdc_tls(opts, tls);
361
362 use sqlx::ConnectOptions as _;
363 let mut conn: PgConnection = opts
364 .connect()
365 .await
366 .map_err(|e| FaucetError::Source(format!("postgres-cdc advance_slot connect: {e}")))?;
367
368 sqlx::query("SELECT pg_replication_slot_advance($1, $2::pg_lsn)")
372 .bind(slot_name)
373 .bind(crate::state::format_lsn(lsn))
374 .execute(&mut conn)
375 .await
376 .map_err(|e| FaucetError::Source(format!("postgres-cdc advance_slot: {e}")))?;
377
378 debug!("postgres-cdc: advanced slot '{slot_name}' confirmed_flush_lsn to {lsn:#x}");
379 Ok(())
380}
381
382pub async fn ensure_slot_and_current_lsn(
399 connection_url: &str,
400 slot_name: &str,
401 create_if_missing: bool,
402 slot_type: crate::config::SlotType,
403 tls: &crate::config::CdcTls,
404) -> Result<u64, FaucetError> {
405 let client = Client { _private: () };
408 ensure_slot(
409 &client,
410 connection_url,
411 slot_name,
412 create_if_missing,
413 slot_type,
414 tls,
415 )
416 .await?;
417
418 let opts: PgConnectOptions = connection_url
419 .parse()
420 .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
421 let opts = apply_cdc_tls(opts, tls);
422 use sqlx::ConnectOptions as _;
423 let mut conn: PgConnection = opts
424 .connect()
425 .await
426 .map_err(|e| FaucetError::Source(format!("postgres-cdc capture_position connect: {e}")))?;
427 let slot_lsn: Option<(Option<String>, Option<String>)> = sqlx::query_as(
429 "SELECT confirmed_flush_lsn::text, restart_lsn::text \
430 FROM pg_replication_slots WHERE slot_name = $1",
431 )
432 .bind(slot_name)
433 .fetch_optional(&mut conn)
434 .await
435 .map_err(|e| FaucetError::Source(format!("postgres-cdc slot lsn lookup: {e}")))?;
436 let anchor = match slot_lsn {
437 Some((confirmed, restart)) => confirmed.or(restart),
438 None => None,
439 };
440 let lsn_text = match anchor {
441 Some(t) => t,
442 None => {
443 let (cur,): (String,) = sqlx::query_as("SELECT pg_current_wal_lsn()::text")
446 .fetch_one(&mut conn)
447 .await
448 .map_err(|e| {
449 FaucetError::Source(format!("postgres-cdc pg_current_wal_lsn: {e}"))
450 })?;
451 cur
452 }
453 };
454 crate::state::parse_lsn(&lsn_text)
455}
456
457pub async fn start_replication(
463 _client: &Client,
464 params: &ReplicationParams<'_>,
465) -> Result<Duplex, FaucetError> {
466 if params.proto_version != 1 {
467 return Err(FaucetError::Config(format!(
468 "postgres-cdc: pgwire-replication 0.3.2 supports proto_version = 1 only; \
469 got {}",
470 params.proto_version
471 )));
472 }
473
474 let coords = parse_url(params.connection_url)?;
475
476 let start_lsn = Lsn::from_u64(params.start_lsn.unwrap_or(0));
477
478 let cfg = ReplicationConfig {
479 host: coords.host,
480 port: coords.port,
481 user: coords.user,
482 password: coords.password,
483 database: coords.dbname,
484 tls: tls_config(params.tls),
485 slot: params.slot_name.to_owned(),
486 publication: params.publication_name.to_owned(),
487 start_lsn,
488 stop_at_lsn: None,
489 status_interval: params.status_update_interval,
492 idle_wakeup_interval: params.status_update_interval,
494 buffer_events: 8192,
495 };
496
497 let inner = ReplicationClient::connect(cfg)
498 .await
499 .map_err(|e| FaucetError::Source(format!("postgres-cdc start_replication: {e}")))?;
500
501 Ok(Duplex { inner })
502}
503
504pub async fn send_status_update(
514 duplex: &mut Duplex,
515 confirmed_lsn: u64,
516 _reply_requested: bool,
517) -> Result<(), FaucetError> {
518 duplex
519 .inner
520 .update_applied_lsn(Lsn::from_u64(confirmed_lsn));
521 Ok(())
522}
523
524pub async fn recv(duplex: &mut Duplex) -> Result<Option<ReplicationEvent>, FaucetError> {
547 loop {
548 match duplex
549 .inner
550 .recv()
551 .await
552 .map_err(|e| FaucetError::Source(format!("postgres-cdc recv: {e}")))?
553 {
554 None => return Ok(None),
555
556 Some(ReplicationEvent::StoppedAt { .. }) => {
557 return Ok(None);
558 }
559
560 Some(ReplicationEvent::KeepAlive { .. }) => {
561 }
564
565 Some(ev) => {
566 return Ok(Some(ev));
571 }
572 }
573 }
574}
575
576pub fn postgres_clock_now() -> i64 {
582 let now = SystemTime::now()
583 .duration_since(UNIX_EPOCH)
584 .unwrap_or_default();
585 let unix_micros = (now.as_secs() as i64) * 1_000_000 + (now.subsec_micros() as i64);
586 unix_micros - POSTGRES_EPOCH_MICROS
587}
588
589pub fn postgres_clock_to_unix_ms(ts: i64) -> i64 {
592 (POSTGRES_EPOCH_MICROS.saturating_add(ts)) / 1_000
593}
594
595#[allow(dead_code)]
601fn quote_slot(s: &str) -> String {
602 format!("\"{}\"", s.replace('"', "\"\""))
603}
604
605fn escape_simple(s: &str) -> String {
608 s.replace('\'', "''")
609}
610
611fn quote_literal(s: &str) -> String {
613 format!("'{}'", escape_simple(s))
614}
615
616pub fn is_slot_active_error(err: &FaucetError) -> bool {
627 let msg = err.to_string().to_ascii_lowercase();
628 msg.contains("is active") || msg.contains("55006")
629}
630
631fn slot_acquire_backoff(attempt: u32) -> Duration {
634 let factor = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
635 let ms = 250u64.saturating_mul(factor).min(4000);
636 Duration::from_millis(ms)
637}
638
639pub async fn retry_on_slot_active<F, Fut, T>(max_retries: u32, op: F) -> Result<T, FaucetError>
644where
645 F: Fn() -> Fut,
646 Fut: std::future::Future<Output = Result<T, FaucetError>>,
647{
648 let mut attempt = 0u32;
649 loop {
650 match op().await {
651 Ok(value) => return Ok(value),
652 Err(e) if attempt < max_retries && is_slot_active_error(&e) => {
653 let backoff = slot_acquire_backoff(attempt);
654 tracing::warn!(
655 attempt = attempt + 1,
656 max_retries,
657 backoff_ms = backoff.as_millis() as u64,
658 error = %e,
659 "postgres-cdc: replication slot still active; retrying after backoff"
660 );
661 tokio::time::sleep(backoff).await;
662 attempt += 1;
663 }
664 Err(e) => return Err(e),
665 }
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::config::CdcTls;
673 use chrono::{TimeZone, Utc};
674 use pgwire_replication::SslMode;
675
676 #[test]
677 fn apply_cdc_tls_sets_ssl_mode_on_control_plane_options() {
678 let base: PgConnectOptions = "postgres://u:p@h:5432/db".parse().unwrap();
681 let dbg = |tls: &CdcTls| format!("{:?}", apply_cdc_tls(base.clone(), tls));
682 assert!(
683 dbg(&CdcTls::Disable).contains("Disable"),
684 "{}",
685 dbg(&CdcTls::Disable)
686 );
687 assert!(dbg(&CdcTls::Require).contains("Require"));
688 assert!(dbg(&CdcTls::VerifyCa { ca_path: None }).contains("VerifyCa"));
689 assert!(
690 dbg(&CdcTls::VerifyFull {
691 ca_path: Some("/ca.pem".into())
692 })
693 .contains("VerifyFull")
694 );
695 }
696
697 #[test]
698 fn tls_config_maps_each_mode() {
699 assert_eq!(tls_config(&CdcTls::Disable).mode, SslMode::Disable);
700 assert_eq!(tls_config(&CdcTls::Require).mode, SslMode::Require);
701 assert_eq!(
702 tls_config(&CdcTls::VerifyCa { ca_path: None }).mode,
703 SslMode::VerifyCa
704 );
705 assert_eq!(
706 tls_config(&CdcTls::VerifyFull {
707 ca_path: Some("/ca.pem".into())
708 })
709 .mode,
710 SslMode::VerifyFull
711 );
712 }
713
714 fn postgres_clock_to_datetime(ts: i64) -> chrono::DateTime<Utc> {
717 Utc.timestamp_micros(POSTGRES_EPOCH_MICROS.saturating_add(ts))
718 .single()
719 .unwrap_or_else(Utc::now)
720 }
721
722 #[test]
723 fn postgres_clock_round_trip() {
724 let dt = Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap();
725 let pg_ts = dt.timestamp_micros() - POSTGRES_EPOCH_MICROS;
726 let back = postgres_clock_to_datetime(pg_ts);
727 assert_eq!(back, dt);
728 }
729
730 #[test]
731 fn unix_ms_conversion() {
732 let dt = Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap();
733 let pg_ts = dt.timestamp_micros() - POSTGRES_EPOCH_MICROS;
734 assert_eq!(postgres_clock_to_unix_ms(pg_ts), 1_779_019_200_000);
735 }
736
737 #[test]
738 fn quote_slot_simple() {
739 assert_eq!(quote_slot("faucet_slot"), "\"faucet_slot\"");
740 }
741
742 #[test]
743 fn escape_simple_doubles_quotes() {
744 assert_eq!(escape_simple("foo'bar"), "foo''bar");
745 }
746
747 #[test]
748 fn parse_url_extracts_all_components() {
749 let c = parse_url("postgres://alice:secret@db.example.com:5544/analytics").unwrap();
750 assert_eq!(c.host, "db.example.com");
751 assert_eq!(c.port, 5544);
752 assert_eq!(c.user, "alice");
753 assert_eq!(c.password, "secret");
754 assert_eq!(c.dbname, "analytics");
755 }
756
757 #[test]
758 fn parse_url_defaults_port_and_dbname() {
759 let c = parse_url("postgres://alice@db.example.com").unwrap();
760 assert_eq!(c.port, 5432);
761 assert_eq!(c.dbname, "postgres");
762 assert_eq!(c.password, "");
763 }
764
765 #[test]
766 fn parse_url_rejects_missing_host() {
767 let err = parse_url("postgres:///analytics").unwrap_err();
769 assert!(format!("{err}").contains("missing a host"), "{err}");
770 }
771
772 #[test]
773 fn parse_url_rejects_missing_user() {
774 let err = parse_url("postgres://db.example.com/analytics").unwrap_err();
775 assert!(format!("{err}").contains("missing a user"), "{err}");
776 }
777
778 #[test]
779 fn is_slot_active_error_classifies_the_postgres_message() {
780 assert!(is_slot_active_error(&FaucetError::Source(
782 "postgres-cdc start_replication: db error: ERROR: replication slot \"s\" \
783 is active for PID 4242"
784 .into()
785 )));
786 assert!(is_slot_active_error(&FaucetError::Source(
788 "55006: replication slot is in use".into()
789 )));
790 assert!(!is_slot_active_error(&FaucetError::Source(
792 "connection refused".into()
793 )));
794 assert!(!is_slot_active_error(&FaucetError::Config(
795 "bad url".into()
796 )));
797 }
798
799 #[test]
800 fn slot_acquire_backoff_grows_and_is_capped() {
801 assert_eq!(slot_acquire_backoff(0), Duration::from_millis(250));
802 assert_eq!(slot_acquire_backoff(1), Duration::from_millis(500));
803 assert_eq!(slot_acquire_backoff(2), Duration::from_millis(1000));
804 assert_eq!(slot_acquire_backoff(20), Duration::from_millis(4000));
806 assert_eq!(slot_acquire_backoff(64), Duration::from_millis(4000));
807 }
808
809 #[tokio::test]
810 async fn retry_on_slot_active_retries_then_succeeds() {
811 use std::sync::atomic::{AtomicU32, Ordering};
812 let calls = AtomicU32::new(0);
813 let result = retry_on_slot_active(5, || {
814 let n = calls.fetch_add(1, Ordering::SeqCst);
815 async move {
816 if n < 2 {
817 Err(FaucetError::Source(
818 "replication slot \"s\" is active for PID 1".into(),
819 ))
820 } else {
821 Ok::<u32, FaucetError>(42)
822 }
823 }
824 })
825 .await;
826 assert_eq!(result.unwrap(), 42);
827 assert_eq!(calls.load(Ordering::SeqCst), 3, "2 failures + 1 success");
828 }
829
830 #[tokio::test]
831 async fn retry_on_slot_active_gives_up_after_max_retries() {
832 use std::sync::atomic::{AtomicU32, Ordering};
833 let calls = AtomicU32::new(0);
834 let result: Result<(), _> = retry_on_slot_active(2, || {
835 calls.fetch_add(1, Ordering::SeqCst);
836 async { Err(FaucetError::Source("slot is active".into())) }
837 })
838 .await;
839 assert!(result.is_err());
840 assert_eq!(
841 calls.load(Ordering::SeqCst),
842 3,
843 "initial attempt + 2 retries"
844 );
845 }
846
847 #[tokio::test]
848 async fn retry_on_slot_active_does_not_retry_unrelated_errors() {
849 use std::sync::atomic::{AtomicU32, Ordering};
850 let calls = AtomicU32::new(0);
851 let result: Result<(), _> = retry_on_slot_active(5, || {
852 calls.fetch_add(1, Ordering::SeqCst);
853 async { Err(FaucetError::Source("connection refused".into())) }
854 })
855 .await;
856 assert!(result.is_err());
857 assert_eq!(
858 calls.load(Ordering::SeqCst),
859 1,
860 "a non-slot-active error must not be retried"
861 );
862 }
863}