Skip to main content

faucet_source_postgres_cdc/
replication.rs

1//! Low-level replication-connection wrapper.
2//!
3//! This module wraps [`pgwire_replication`] to provide the slot lifecycle
4//! (`ensure_slot`) and streaming helpers (`start_replication`, `recv`,
5//! `send_status_update`) used by the rest of the CDC source.
6//!
7//! # Design
8//!
9//! `pgwire_replication` handles everything from TCP connect through auth,
10//! `START_REPLICATION`, keepalive replies, and `StandbyStatusUpdate` — all
11//! internally.  The library delivers events as a typed enum; [`recv`] surfaces
12//! the full [`ReplicationEvent`] to callers (absorbing only [`KeepAlive`] and
13//! [`StoppedAt`] internally) so Tasks 9+ can observe transaction boundaries.
14//!
15//! Slot creation (`CREATE_REPLICATION_SLOT`) is a control-plane operation that
16//! requires an ordinary (non-replication) SQL connection, so `ensure_slot`
17//! uses [`sqlx`] for that single query.
18//!
19//! # Type aliases
20//!
21//! The plan requires stable names `Client` and `Duplex` so that Tasks 9+ can
22//! refer to concrete types.  We define:
23//!
24//! - [`Client`] — a lightweight holder of `ReplicationParams` used to verify
25//!   connectivity and create the replication slot, before the stream is
26//!   opened.
27//! - [`Duplex`] — the live replication stream; a thin wrapper around
28//!   [`pgwire_replication::ReplicationClient`].
29//!
30//! [`KeepAlive`]: ReplicationEvent::KeepAlive
31//! [`StoppedAt`]: ReplicationEvent::StoppedAt
32
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35use faucet_core::FaucetError;
36use pgwire_replication::{Lsn, ReplicationClient, ReplicationConfig, TlsConfig};
37use sqlx::postgres::PgConnectOptions;
38
39/// Re-export so downstream modules (`stream.rs`, Task 9) can import the event
40/// type without depending on `pgwire_replication` directly.
41pub use pgwire_replication::ReplicationEvent;
42use sqlx::{Executor, PgConnection};
43use tracing::debug;
44
45/// Microseconds between the Unix epoch (1970-01-01) and the Postgres epoch
46/// (2000-01-01).  Used for converting between Postgres timestamps and Unix time.
47pub const POSTGRES_EPOCH_MICROS: i64 = 946_684_800_000_000;
48
49// ── Public type aliases ────────────────────────────────────────────────────
50
51/// Pre-stream handle returned by [`connect`] once the connection URL has been
52/// validated. The actual connection is opened by [`ensure_slot`] /
53/// [`start_replication`] from the borrowed [`ReplicationParams`], so this is a
54/// lightweight marker — it deliberately holds no owned copy of the connection
55/// string, which previously sat in leaked (`Box::leak`) heap for the process
56/// lifetime, including the password (#78/#13).
57pub struct Client {
58    _private: (),
59}
60
61/// Live replication stream.  Wraps [`pgwire_replication::ReplicationClient`].
62/// Obtained from [`start_replication`].
63pub struct Duplex {
64    inner: ReplicationClient,
65}
66
67// ── Parameters ────────────────────────────────────────────────────────────
68
69/// All parameters required to establish a logical replication connection.
70///
71/// This struct is accepted by every function in this module.
72#[derive(Clone, Debug)]
73pub struct ReplicationParams<'a> {
74    /// `postgres://user:pass@host:port/db` style URL.
75    pub connection_url: &'a str,
76    /// Name of the replication slot (must already exist, or `create_slot_if_missing = true`).
77    pub slot_name: &'a str,
78    /// Publication name — must already exist on the server.
79    pub publication_name: &'a str,
80    /// pgoutput protocol version. Only `1` is currently supported.
81    pub proto_version: u32,
82    /// Create the slot if it does not already exist.
83    pub create_slot_if_missing: bool,
84    /// Optional LSN to resume from.  `None` means "start from the slot's
85    /// `confirmed_flush_lsn`".
86    pub start_lsn: Option<u64>,
87    /// Protocol-level Standby Status Update cadence — must be shorter than
88    /// the server's `wal_sender_timeout`.
89    pub status_update_interval: Duration,
90    /// TCP-level keepalive interval. Larger than `status_update_interval`
91    /// in normal operation.
92    pub tcp_keepalive: Duration,
93    /// Whether a newly-created slot is permanent or temporary.
94    pub slot_type: crate::config::SlotType,
95    /// TLS settings for the replication connection.
96    pub tls: &'a crate::config::CdcTls,
97}
98
99// ── Helper: parse a postgres URL into (host, port, user, password, dbname) ─
100
101#[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    // Parse via the standard `url` crate so we can extract all components
112    // without relying on sqlx accessor methods that may not be public.
113    let parsed = url::Url::parse(url)
114        .map_err(|e| FaucetError::Config(format!("postgres-cdc: invalid connection URL: {e}")))?;
115
116    // Fail fast on a missing host or user rather than silently defaulting to
117    // `localhost` / empty — a malformed URL otherwise connects to an
118    // unintended local instance or produces a confusing late auth failure
119    // (#78/#47). Port (5432) and dbname (the user's DB) keep their standard
120    // libpq-style defaults since omitting them is conventional.
121    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
158// ── Public API ─────────────────────────────────────────────────────────────
159
160/// Validate connectivity and return a [`Client`] handle.
161///
162/// This function parses the connection URL and records the parameters.
163/// It does **not** open a TCP connection; actual connectivity is verified
164/// lazily when [`ensure_slot`] or [`start_replication`] is called.
165pub async fn connect(params: &ReplicationParams<'_>) -> Result<Client, FaucetError> {
166    // Eagerly validate the URL so bad configs fail fast. No part of the
167    // connection string is retained past this call.
168    let _ = parse_url(params.connection_url)?;
169    Ok(Client { _private: () })
170}
171
172/// Ensure the replication slot exists.
173///
174/// If the slot already exists this is a no-op.  If it does not exist and
175/// `create_if_missing` is `true`, the slot is created via
176/// `pg_create_logical_replication_slot`.  If `create_if_missing` is `false`
177/// and the slot is absent, an error is returned.
178pub 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    // Use sqlx for the control-plane query (not a replication connection).
188    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    // Check whether the slot already exists.
200    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    // Create the slot using the pgoutput plugin. The third arg to
220    // pg_create_logical_replication_slot is `temporary`: a temporary slot is
221    // dropped when this session disconnects; a permanent slot persists (and
222    // pins WAL) until explicitly dropped.
223    // `escape_simple` prevents injection via the slot name (already validated
224    // to [a-z0-9_] by config, but defence-in-depth doesn't hurt).
225    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        // A permanent slot retains WAL until consumed or dropped — surface it
239        // loudly so an abandoned slot doesn't silently fill pg_wal (#78/#12).
240        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
249/// Drop a logical replication slot via a control-plane SQL call
250/// (`pg_drop_replication_slot`). A missing slot is treated as success (no-op);
251/// an active slot (currently in use by another connection) surfaces an error.
252pub 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
287/// Apply the CDC [`CdcTls`](crate::config::CdcTls) policy onto the sqlx
288/// `PgConnectOptions` used for the **control-plane** connections (slot
289/// create/drop/advance, LSN capture). Without this these privileged
290/// connections would use sqlx's default `Prefer` SSL mode — opportunistic and
291/// unverified — so `tls: verify_full` would silently fail to protect them, a
292/// TLS-downgrade / MITM window on the same credentials the replication stream
293/// guards (audit #321 M5). The explicit policy overrides any `sslmode=` in the
294/// URL (the config is authoritative).
295fn 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
318/// Map the user-facing [`CdcTls`](crate::config::CdcTls) config onto
319/// pgwire-replication's [`TlsConfig`].
320fn 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
333/// Advance the slot's `confirmed_flush_lsn` to `lsn` via a control-plane SQL
334/// call (`pg_replication_slot_advance`), **before** the replication stream is
335/// opened.
336///
337/// This is how the connector resumes past already-consumed, durably-persisted
338/// changes without ever advancing the slot ahead of durability (#78/#1). For
339/// a logical slot, `START_REPLICATION` resumes decoding from the slot's
340/// `confirmed_flush_lsn` — the client-supplied start LSN does not filter
341/// transactions that committed below it — so the only way to skip consumed
342/// changes is to move `confirmed_flush_lsn` forward here, while the slot is
343/// inactive. `pg_replication_slot_advance` never moves a slot backwards or
344/// past the server's insert pointer, so a stale or zero `lsn` is a safe no-op.
345///
346/// The slot must be inactive, which it is between [`ensure_slot`] and
347/// [`start_replication`].
348pub 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    // Bind the slot name and the LSN (as pg_lsn text) as parameters — no string
369    // interpolation into the SQL. `format_lsn` emits Postgres' canonical
370    // `X/X` text form.
371    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
382/// Ensure the slot exists, then return the slot's **own consistent point** —
383/// its `confirmed_flush_lsn` (falling back to `restart_lsn`, then the server's
384/// `pg_current_wal_lsn()` only if both are null).
385///
386/// Anchoring on the slot's consistent point — rather than the server's *current*
387/// WAL position — is what makes capture-before-snapshot gapless even when the
388/// slot **already existed** with unconsumed WAL behind it. Using
389/// `pg_current_wal_lsn()` would skip every change the slot had retained between
390/// its confirmed position and "now", silently losing those CDC events (F8).
391/// For a freshly-created logical slot, `confirmed_flush_lsn` is the slot's
392/// creation consistent point, so the fresh-slot behaviour is preserved.
393///
394/// Ensuring the slot **first** guarantees the server retains WAL from the
395/// returned LSN onward, so a later `START_REPLICATION` from this position has
396/// no gap. Used by the replication snapshot→CDC handoff
397/// ([`Source::capture_resume_position`](faucet_core::Source::capture_resume_position)).
398pub 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    // `ensure_slot`'s `_client` arg is unused; `Client` is constructible here
406    // (same module). This reuses the existing slot existence/create logic.
407    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    // Prefer the slot's retained consistent point over the server's current LSN.
428    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            // Slot reports no LSN yet (e.g. just-created temporary slot on some
444            // versions) — fall back to the server's current WAL position.
445            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
457/// Open a logical replication stream and return a [`Duplex`] handle.
458///
459/// Internally this calls `pgwire_replication::ReplicationClient::connect`
460/// which handles TCP, TLS negotiation, auth, and `START_REPLICATION` in one
461/// shot.
462pub 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        // Use the dedicated status-update interval (not tcp_keepalive) so that
490        // Standby Status Updates fire on their own cadence.
491        status_interval: params.status_update_interval,
492        // Wake up the worker at least as often as we send status updates.
493        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
504/// Report progress to the server (Standby Status Update).
505///
506/// `confirmed_lsn` is the highest LSN whose changes have been durably
507/// written to the sink.  The underlying library sends this feedback on its
508/// own keepalive schedule; calling this function additionally marks the
509/// progress so the next automatic feedback includes the latest position.
510///
511/// `reply_requested` mirrors the flag from the server's KeepAlive message
512/// (no-op here since the library handles immediate replies internally).
513pub 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
524/// Receive the next meaningful replication event from the server.
525///
526/// Returns:
527/// - `Ok(Some(event))` — the next [`ReplicationEvent`] that the caller should
528///   handle.  This includes [`ReplicationEvent::XLogData`],
529///   [`ReplicationEvent::Begin`], [`ReplicationEvent::Commit`], and
530///   [`ReplicationEvent::Message`].  Callers (Task 9+) can match on the full
531///   event type to observe transaction boundaries.
532/// - `Ok(None)` — stream ended cleanly (slot stopped, stop LSN reached, or
533///   `Duplex` was shut down).
534/// - `Err(_)` — network / protocol error.
535///
536/// [`ReplicationEvent::KeepAlive`] events are absorbed here.  We deliberately
537/// do **not** advance the applied-LSN to the server's `wal_end` on a keepalive
538/// (the previous behaviour): that position is not yet durable downstream, and
539/// advertising it as `confirmed_flush_lsn` would authorise Postgres to recycle
540/// WAL for changes the consumer never persisted — a crash in that window loses
541/// data (#78/#1).  The applied-LSN is advanced only from the durable bookmark,
542/// via [`send_status_update`] at the start of each run; the library keeps
543/// sending its periodic Standby Status Updates (carrying that durable
544/// position) to hold the connection open.  [`ReplicationEvent::StoppedAt`] is
545/// converted to `Ok(None)`.
546pub 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                // Absorb keepalives without touching the applied-LSN — see the
562                // function doc. Continue the loop; do not surface to the caller.
563            }
564
565            Some(ev) => {
566                // Surface Begin, Commit, XLogData, Message (and any future
567                // variants) to the caller. The commit_lsn carried by Commit is
568                // what becomes the durable bookmark once the pipeline persists
569                // it — that, not wal_end, is the only position fed back to PG.
570                return Ok(Some(ev));
571            }
572        }
573    }
574}
575
576// ── Clock helpers ──────────────────────────────────────────────────────────
577
578/// Current time as a Postgres-epoch timestamp (µs since 2000-01-01 UTC).
579///
580/// Used in Standby Status Update messages.
581pub 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
589/// Convert a Postgres-epoch timestamp (µs since 2000-01-01) to Unix
590/// milliseconds (ms since 1970-01-01).
591pub fn postgres_clock_to_unix_ms(ts: i64) -> i64 {
592    (POSTGRES_EPOCH_MICROS.saturating_add(ts)) / 1_000
593}
594
595// ── Private SQL helpers ────────────────────────────────────────────────────
596
597/// Wrap `s` in double-quotes for use as a Postgres identifier.
598/// Any embedded double-quote is doubled (`"` → `""`).
599/// Reserved for DDL statements (e.g. `DROP REPLICATION SLOT`); used in tests.
600#[allow(dead_code)]
601fn quote_slot(s: &str) -> String {
602    format!("\"{}\"", s.replace('"', "\"\""))
603}
604
605/// Escape a string for use in a Postgres literal (single-quote context).
606/// Any embedded single-quote is doubled (`'` → `''`).
607fn escape_simple(s: &str) -> String {
608    s.replace('\'', "''")
609}
610
611/// Produce a single-quoted Postgres string literal.
612fn quote_literal(s: &str) -> String {
613    format!("'{}'", escape_simple(s))
614}
615
616// ── Tests ──────────────────────────────────────────────────────────────────
617
618/// Returns `true` if `err` is Postgres reporting that the replication slot is
619/// still **active** — held by a backend that has not yet released it. Postgres
620/// raises *"replication slot \"…\" is active for PID …"* (SQLSTATE `55006`).
621///
622/// This is transient on a rapid restart: a scheduler or `serve` re-running the
623/// pipeline before the previous connection's backend has fully exited finds the
624/// slot momentarily still in use. It clears within a short window, so it is
625/// safe to retry after a backoff (#146 M12).
626pub 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
631/// Exponential backoff for slot-acquisition retries: `250ms · 2^attempt`,
632/// capped at 4 s.
633fn 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
639/// Run `op`, retrying up to `max_retries` times with exponential backoff while
640/// it fails because the replication slot is still active (#146 M12). Any other
641/// error — and the final attempt's error after exhausting retries — is returned
642/// immediately. `max_retries = 0` preserves the previous fail-fast behaviour.
643pub 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        // #321 M5: the control-plane PgConnectOptions must carry the configured
679        // SSL mode, not sqlx's default `Prefer`. Assert via the Debug view.
680        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    /// Convenience: turn `postgres_clock_to_unix_ms`-compatible math into a
715    /// `DateTime<Utc>`.  Used by tests only.
716    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        // `postgres:///db` parses with an empty host — must fail fast (#78/#47).
768        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        // The canonical Postgres message (SQLSTATE 55006).
781        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        // SQLSTATE code present.
787        assert!(is_slot_active_error(&FaucetError::Source(
788            "55006: replication slot is in use".into()
789        )));
790        // Unrelated errors are NOT slot-active.
791        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        // Capped at 4s no matter how large the attempt.
805        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}