use crate::error::{CdcError, Result};
use pg_walstream::{parse_lsn, PgReplicationConnection};
use std::time::Duration;
use tracing::debug;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SlotStatus {
pub(crate) confirmed_flush_lsn: Option<u64>,
pub(crate) restart_lsn: Option<u64>,
pub(crate) active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResumeSource {
Slot,
SlotDeletedFallback,
QueryFailedFallback,
Fresh,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResumeDecision {
pub(crate) start_lsn: Option<u64>,
pub(crate) dedup_boundary: u64,
pub(crate) source: ResumeSource,
}
pub(crate) fn reconcile_resume(
disk: Option<u64>,
slot: std::result::Result<Option<&SlotStatus>, ()>,
) -> ResumeDecision {
match slot {
Ok(Some(s)) => ResumeDecision {
start_lsn: s.confirmed_flush_lsn,
dedup_boundary: disk.unwrap_or(0).max(s.confirmed_flush_lsn.unwrap_or(0)),
source: ResumeSource::Slot,
},
Ok(None) => match disk {
Some(d) => ResumeDecision {
start_lsn: None,
dedup_boundary: d,
source: ResumeSource::SlotDeletedFallback,
},
None => ResumeDecision {
start_lsn: None,
dedup_boundary: 0,
source: ResumeSource::Fresh,
},
},
Err(()) => match disk {
Some(d) => ResumeDecision {
start_lsn: None,
dedup_boundary: d,
source: ResumeSource::QueryFailedFallback,
},
None => ResumeDecision {
start_lsn: None,
dedup_boundary: 0,
source: ResumeSource::Fresh,
},
},
}
}
pub(crate) fn validate_slot_name(slot_name: &str) -> Result<()> {
if slot_name.is_empty() {
return Err(CdcError::config("Replication slot name is required"));
}
if !slot_name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
{
return Err(CdcError::config(format!(
"Invalid replication slot name {slot_name:?}: only lower-case letters, digits, and underscores are allowed"
)));
}
Ok(())
}
fn build_slot_query(slot_name: &str) -> String {
let escaped = slot_name.replace('\'', "''");
format!(
"SELECT restart_lsn, confirmed_flush_lsn, active \
FROM pg_replication_slots WHERE slot_name = '{escaped}'"
)
}
fn parse_lsn_field(value: Option<String>) -> Option<u64> {
let v = value?;
let t = v.trim();
if t.is_empty() {
return None;
}
parse_lsn(t).ok()
}
fn parse_active_field(value: Option<String>) -> bool {
value
.map(|s| matches!(s.trim(), "t" | "true" | "1"))
.unwrap_or(false)
}
fn query_slot_blocking(conninfo: &str, slot_name: &str) -> Result<Option<SlotStatus>> {
let mut conn = PgReplicationConnection::connect(conninfo)?;
let result = conn.exec(&build_slot_query(slot_name))?;
if result.ntuples() == 0 {
return Ok(None);
}
Ok(Some(SlotStatus {
restart_lsn: parse_lsn_field(result.get_value(0, 0)),
confirmed_flush_lsn: parse_lsn_field(result.get_value(0, 1)),
active: parse_active_field(result.get_value(0, 2)),
}))
}
pub(crate) async fn query_replication_slot(
conninfo: &str,
slot_name: &str,
timeout: Duration,
) -> Result<Option<SlotStatus>> {
validate_slot_name(slot_name)?;
let conninfo = conninfo.to_string();
let slot_name = slot_name.to_string();
debug!("Querying pg_replication_slots for slot '{}'", slot_name);
let handle = tokio::task::spawn_blocking(move || query_slot_blocking(&conninfo, &slot_name));
match tokio::time::timeout(timeout, handle).await {
Ok(Ok(res)) => res,
Ok(Err(join_err)) => Err(CdcError::generic(format!(
"replication slot query task failed: {join_err}"
))),
Err(_elapsed) => Err(CdcError::connection(format!(
"replication slot query timed out after {timeout:?}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slot(confirmed: Option<u64>, restart: Option<u64>, active: bool) -> SlotStatus {
SlotStatus {
confirmed_flush_lsn: confirmed,
restart_lsn: restart,
active,
}
}
#[test]
fn slot_present_disk_behind_boundary_is_slot() {
let d = reconcile_resume(Some(100), Ok(Some(&slot(Some(500), Some(400), false))));
assert_eq!(d.start_lsn, Some(500));
assert_eq!(d.dedup_boundary, 500);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_present_disk_ahead_boundary_is_disk() {
let d = reconcile_resume(Some(900), Ok(Some(&slot(Some(500), Some(400), false))));
assert_eq!(d.start_lsn, Some(500));
assert_eq!(d.dedup_boundary, 900);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_present_disk_equal() {
let d = reconcile_resume(Some(500), Ok(Some(&slot(Some(500), Some(400), false))));
assert_eq!(d.dedup_boundary, 500);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_present_disk_missing() {
let d = reconcile_resume(None, Ok(Some(&slot(Some(500), Some(400), false))));
assert_eq!(d.start_lsn, Some(500));
assert_eq!(d.dedup_boundary, 500);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_present_confirmed_null() {
let d = reconcile_resume(Some(700), Ok(Some(&slot(None, Some(400), false))));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 700);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_present_confirmed_null_disk_missing() {
let d = reconcile_resume(None, Ok(Some(&slot(None, None, false))));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 0);
assert_eq!(d.source, ResumeSource::Slot);
}
#[test]
fn slot_absent_disk_present_is_fallback() {
let d = reconcile_resume(Some(700), Ok(None));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 700);
assert_eq!(d.source, ResumeSource::SlotDeletedFallback);
}
#[test]
fn query_failed_disk_present_is_fallback() {
let d = reconcile_resume(Some(700), Err(()));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 700);
assert_eq!(d.source, ResumeSource::QueryFailedFallback);
}
#[test]
fn query_failed_disk_missing_is_fresh() {
let d = reconcile_resume(None, Err(()));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 0);
assert_eq!(d.source, ResumeSource::Fresh);
}
#[test]
fn both_absent_is_fresh() {
let d = reconcile_resume(None, Ok(None));
assert_eq!(d.start_lsn, None);
assert_eq!(d.dedup_boundary, 0);
assert_eq!(d.source, ResumeSource::Fresh);
}
#[test]
fn lsn_text_parses_like_postgres() {
assert_eq!(pg_walstream::parse_lsn("0/0").unwrap(), 0);
assert_eq!(
pg_walstream::parse_lsn("16/B374D848").unwrap(),
0x16B374D848
);
assert!(pg_walstream::parse_lsn("not-an-lsn").is_err());
}
#[test]
fn validate_slot_name_accepts_legal_names() {
assert!(validate_slot_name("pg2any_slot").is_ok());
assert!(validate_slot_name("slot_123").is_ok());
assert!(validate_slot_name("a").is_ok());
}
#[test]
fn validate_slot_name_rejects_illegal_names() {
assert!(validate_slot_name("").is_err()); assert!(validate_slot_name("a'b").is_err()); assert!(validate_slot_name("slot; DROP TABLE t").is_err()); assert!(validate_slot_name("MySlot").is_err()); assert!(validate_slot_name("my-slot").is_err()); assert!(validate_slot_name("my slot").is_err()); assert!(validate_slot_name("slot\\").is_err()); }
#[test]
fn build_query_contains_slot_and_columns() {
let sql = build_slot_query("pg2any_slot");
assert!(sql.contains("pg_replication_slots"));
assert!(sql.contains("restart_lsn"));
assert!(sql.contains("confirmed_flush_lsn"));
assert!(sql.contains("slot_name = 'pg2any_slot'"));
}
#[test]
fn build_query_escapes_single_quote() {
let sql = build_slot_query("a'b");
assert!(sql.contains("slot_name = 'a''b'"));
}
#[test]
fn parse_lsn_field_valid_values() {
assert_eq!(parse_lsn_field(Some("0/0".to_string())), Some(0));
assert_eq!(
parse_lsn_field(Some("16/B374D848".to_string())),
Some(0x16B374D848)
);
assert_eq!(
parse_lsn_field(Some(" 16/B374D848 ".to_string())),
Some(0x16B374D848)
);
}
#[test]
fn parse_lsn_field_null_empty_invalid_are_none() {
assert_eq!(parse_lsn_field(None), None); assert_eq!(parse_lsn_field(Some(String::new())), None); assert_eq!(parse_lsn_field(Some(" ".to_string())), None); assert_eq!(parse_lsn_field(Some("garbage".to_string())), None); assert_eq!(parse_lsn_field(Some("16".to_string())), None); assert_eq!(parse_lsn_field(Some("zz/zz".to_string())), None); }
#[test]
fn parse_active_field_true_values() {
assert!(parse_active_field(Some("t".to_string())));
assert!(parse_active_field(Some("true".to_string())));
assert!(parse_active_field(Some("1".to_string())));
assert!(parse_active_field(Some(" t ".to_string()))); }
#[test]
fn parse_active_field_false_values() {
assert!(!parse_active_field(Some("f".to_string())));
assert!(!parse_active_field(Some("false".to_string())));
assert!(!parse_active_field(Some("0".to_string())));
assert!(!parse_active_field(Some("yes".to_string()))); assert!(!parse_active_field(None)); assert!(!parse_active_field(Some(String::new())));
}
#[tokio::test]
async fn query_replication_slot_rejects_invalid_name() {
let dsn = "host=192.0.2.1 port=5432 dbname=postgres user=postgres";
let res = query_replication_slot(dsn, "bad'; DROP", Duration::from_secs(5)).await;
assert!(
res.is_err(),
"invalid slot name must be rejected, got {res:?}"
);
}
#[tokio::test]
async fn query_replication_slot_connect_failure_is_err() {
let dsn = "host=127.0.0.1 port=1 dbname=postgres user=postgres connect_timeout=1";
let res = query_replication_slot(dsn, "any_slot", Duration::from_secs(5)).await;
assert!(
res.is_err(),
"connect failure should map to Err (fallback trigger), got {res:?}"
);
}
#[tokio::test]
async fn query_replication_slot_unreachable_host_is_err() {
let dsn = "host=192.0.2.1 port=5432 dbname=postgres user=postgres";
let res = query_replication_slot(dsn, "any_slot", Duration::from_millis(200)).await;
assert!(
res.is_err(),
"unreachable host should map to Err (fallback trigger), got {res:?}"
);
}
const IT_SLOT: &str = "pg2any_it_slot_lsn_recovery";
#[tokio::test]
async fn query_replication_slot_roundtrip_live() {
let Ok(dsn) = std::env::var("PG2ANY_TEST_SOURCE_DSN") else {
eprintln!("PG2ANY_TEST_SOURCE_DSN not set; skipping live slot roundtrip test");
return;
};
let d = dsn.clone();
tokio::task::spawn_blocking(move || {
if let Ok(mut c) = PgReplicationConnection::connect(&d) {
let _ = c.exec(&format!("SELECT pg_drop_replication_slot('{IT_SLOT}')"));
}
let mut c = PgReplicationConnection::connect(&d).expect("connect for setup");
c.exec(&format!(
"SELECT pg_create_logical_replication_slot('{IT_SLOT}', 'pgoutput')"
))
.expect("create logical slot");
})
.await
.expect("setup task");
let status = query_replication_slot(&dsn, IT_SLOT, Duration::from_secs(10))
.await
.expect("query should succeed")
.expect("slot should be found");
assert!(
status.confirmed_flush_lsn.is_some(),
"confirmed_flush_lsn should be present for a fresh logical slot"
);
assert!(
status.restart_lsn.is_some(),
"restart_lsn should be present for a fresh logical slot"
);
assert!(
!status.active,
"a freshly created, unstreamed slot should not be active"
);
let decision = reconcile_resume(Some(1), Ok(Some(&status)));
assert_eq!(decision.source, ResumeSource::Slot);
assert_eq!(
decision.dedup_boundary,
status.confirmed_flush_lsn.unwrap().max(1)
);
let d = dsn.clone();
tokio::task::spawn_blocking(move || {
let mut c = PgReplicationConnection::connect(&d).expect("connect for teardown");
c.exec(&format!("SELECT pg_drop_replication_slot('{IT_SLOT}')"))
.expect("drop slot");
})
.await
.expect("teardown task");
let gone = query_replication_slot(&dsn, IT_SLOT, Duration::from_secs(10))
.await
.expect("query should succeed");
assert!(gone.is_none(), "slot should be gone after drop");
}
}