pgsync_postgres/replication/
slot.rs1use super::conn::{ReplicationConnection, SimpleResult};
4use pgsync_core::config::{quote_ident, quote_literal};
5use pgsync_core::{Error, Lsn, Result};
6
7#[derive(Debug, Clone)]
8pub struct SystemInfo {
9 pub system_id: String,
10 pub timeline: u64,
11 pub xlogpos: Lsn,
12 pub dbname: Option<String>,
13}
14
15pub async fn identify_system(conn: &mut ReplicationConnection) -> Result<SystemInfo> {
16 let r = conn.simple_query("IDENTIFY_SYSTEM").await?;
17 let get = |c: &str| r.get(0, c).map(str::to_owned);
18 Ok(SystemInfo {
19 system_id: get("systemid")
20 .ok_or_else(|| Error::internal("IDENTIFY_SYSTEM: missing systemid"))?,
21 timeline: get("timeline").and_then(|t| t.parse().ok()).unwrap_or(0),
22 xlogpos: get("xlogpos")
23 .ok_or_else(|| Error::internal("IDENTIFY_SYSTEM: missing xlogpos"))?
24 .parse()
25 .map_err(|e| Error::internal(format!("IDENTIFY_SYSTEM: {e}")))?,
26 dbname: get("dbname"),
27 })
28}
29
30#[derive(Debug, Clone)]
31pub struct CreatedSlot {
32 pub slot_name: String,
33 pub consistent_point: Lsn,
34 pub snapshot_name: String,
35}
36
37pub async fn create_slot_with_snapshot(
41 conn: &mut ReplicationConnection,
42 slot: &str,
43) -> Result<CreatedSlot> {
44 let cmd = format!(
45 "CREATE_REPLICATION_SLOT {} LOGICAL pgoutput EXPORT_SNAPSHOT",
46 quote_ident(slot)
47 );
48 let r = conn.simple_query(&cmd).await?;
49 let consistent_point: Lsn = r
50 .get(0, "consistent_point")
51 .ok_or_else(|| Error::internal("CREATE_REPLICATION_SLOT: missing consistent_point"))?
52 .parse()
53 .map_err(|e| Error::internal(format!("CREATE_REPLICATION_SLOT: {e}")))?;
54 let snapshot_name = r
55 .get(0, "snapshot_name")
56 .ok_or_else(|| Error::internal("CREATE_REPLICATION_SLOT: missing snapshot_name"))?
57 .to_owned();
58 Ok(CreatedSlot {
59 slot_name: slot.to_owned(),
60 consistent_point,
61 snapshot_name,
62 })
63}
64
65pub async fn drop_slot(conn: &mut ReplicationConnection, slot: &str) -> Result<()> {
66 conn.simple_query(&format!("DROP_REPLICATION_SLOT {} WAIT", quote_ident(slot)))
67 .await?;
68 Ok(())
69}
70
71#[derive(Debug, Clone, Default, serde::Serialize)]
73pub struct SlotInfo {
74 pub slot_name: String,
75 pub plugin: Option<String>,
76 pub slot_type: Option<String>,
77 pub database: Option<String>,
78 pub active: bool,
79 pub active_pid: Option<i64>,
80 pub restart_lsn: Option<Lsn>,
81 pub confirmed_flush_lsn: Option<Lsn>,
82 pub wal_status: Option<String>,
83 pub safe_wal_size: Option<i64>,
84 pub invalidation_reason: Option<String>,
85 pub inactive_since: Option<String>,
86}
87
88pub const SLOT_QUERY: &str =
89 "SELECT to_jsonb(s)::text AS j FROM pg_catalog.pg_replication_slots s WHERE s.slot_name = ";
90
91pub fn slot_info_from_json(json: &str) -> Result<SlotInfo> {
92 let v: serde_json::Value = serde_json::from_str(json)
93 .map_err(|e| Error::internal(format!("pg_replication_slots: {e}")))?;
94 let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_owned);
95 let lsn = |k: &str| -> Result<Option<Lsn>> {
96 match s(k) {
97 Some(t) => Ok(Some(
98 t.parse()
99 .map_err(|e| Error::internal(format!("{k}: {e}")))?,
100 )),
101 None => Ok(None),
102 }
103 };
104 Ok(SlotInfo {
105 slot_name: s("slot_name").unwrap_or_default(),
106 plugin: s("plugin"),
107 slot_type: s("slot_type"),
108 database: s("database"),
109 active: v.get("active").and_then(|x| x.as_bool()).unwrap_or(false),
110 active_pid: v.get("active_pid").and_then(|x| x.as_i64()),
111 restart_lsn: lsn("restart_lsn")?,
112 confirmed_flush_lsn: lsn("confirmed_flush_lsn")?,
113 wal_status: s("wal_status"),
114 safe_wal_size: v.get("safe_wal_size").and_then(|x| x.as_i64()),
115 invalidation_reason: s("invalidation_reason"),
116 inactive_since: s("inactive_since"),
117 })
118}
119
120pub async fn read_slot(conn: &mut ReplicationConnection, slot: &str) -> Result<Option<SlotInfo>> {
121 let r: SimpleResult = conn
122 .simple_query(&format!("{SLOT_QUERY}{}", quote_literal(slot)))
123 .await?;
124 match r.get(0, "j") {
125 Some(json) => Ok(Some(slot_info_from_json(json)?)),
126 None => Ok(None),
127 }
128}
129
130pub fn start_replication_command(slot: &str, from: Lsn, publication: &str) -> String {
132 format!(
133 "START_REPLICATION SLOT {} LOGICAL {} (proto_version '1', publication_names {})",
134 quote_ident(slot),
135 from,
136 quote_literal(publication)
137 )
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn parses_slot_json_with_optional_columns() {
146 let json = r#"{"slot_name":"s1","plugin":"pgoutput","slot_type":"logical","database":"app","active":true,"active_pid":123,"restart_lsn":"0/16B3748","confirmed_flush_lsn":"0/16B3780","wal_status":"reserved","safe_wal_size":null,"two_phase":false}"#;
147 let s = slot_info_from_json(json).unwrap();
148 assert_eq!(s.plugin.as_deref(), Some("pgoutput"));
149 assert!(s.active);
150 assert_eq!(s.restart_lsn, Some(Lsn(0x16B3748)));
151 assert_eq!(s.confirmed_flush_lsn, Some(Lsn(0x16B3780)));
152 assert_eq!(s.invalidation_reason, None);
153 let pg17 = r#"{"slot_name":"s1","active":false,"invalidation_reason":"wal_removed","wal_status":"lost","restart_lsn":null}"#;
154 let s = slot_info_from_json(pg17).unwrap();
155 assert_eq!(s.invalidation_reason.as_deref(), Some("wal_removed"));
156 assert_eq!(s.restart_lsn, None);
157 }
158
159 #[test]
160 fn start_command_quotes_safely() {
161 let c = start_replication_command("my_slot", Lsn(0x16B3748), "pub1");
162 assert_eq!(
163 c,
164 "START_REPLICATION SLOT \"my_slot\" LOGICAL 0/16B3748 (proto_version '1', publication_names 'pub1')"
165 );
166 }
167}