krabka_connect_postgres/
offset.rs1use std::{fmt, str::FromStr};
2
3use krabka_connect::{OffsetMap, OffsetValue, SourceOffset};
4
5use crate::PostgresConnectError;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct PgLsn(pub u64);
9
10impl PgLsn {
11 #[must_use]
12 pub fn to_source_offset(self, database: &str, slot: &str) -> SourceOffset {
13 let partition = OffsetMap::from([
14 (
15 "database".to_owned(),
16 OffsetValue::String(database.to_owned()),
17 ),
18 ("slot".to_owned(), OffsetValue::String(slot.to_owned())),
19 ]);
20 let position = OffsetMap::from([("lsn".to_owned(), OffsetValue::String(self.to_string()))]);
21
22 SourceOffset::new(partition.into(), position.into())
23 }
24
25 pub fn from_source_offset(
28 offset: &SourceOffset,
29 expected_slot: &str,
30 ) -> Result<PgLsn, PostgresConnectError> {
31 match offset.partition.get("slot") {
32 Some(OffsetValue::String(slot)) if slot == expected_slot => {}
33 Some(OffsetValue::String(slot)) => {
34 return Err(PostgresConnectError::Offset(format!(
35 "source offset slot {slot:?} does not match expected slot {expected_slot:?}"
36 )));
37 }
38 _ => {
39 return Err(PostgresConnectError::Offset(
40 "source offset missing string slot".to_owned(),
41 ));
42 }
43 }
44
45 match offset.position.get("lsn") {
46 Some(OffsetValue::String(lsn)) => lsn.parse(),
47 _ => Err(PostgresConnectError::Offset(
48 "source offset missing string lsn".to_owned(),
49 )),
50 }
51 }
52}
53
54impl fmt::Display for PgLsn {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 let hi = self.0 >> 32;
57 let lo = self.0 & 0xffff_ffff;
58
59 write!(f, "{hi:X}/{lo:X}")
60 }
61}
62
63impl FromStr for PgLsn {
64 type Err = PostgresConnectError;
65
66 fn from_str(s: &str) -> Result<Self, Self::Err> {
67 let (hi, lo) = s
68 .split_once('/')
69 .ok_or_else(|| PostgresConnectError::Offset(format!("invalid Postgres LSN {s:?}")))?;
70
71 let hi = parse_lsn_half(hi, s)?;
72 let lo = parse_lsn_half(lo, s)?;
73
74 Ok(PgLsn((hi << 32) | lo))
75 }
76}
77
78fn parse_lsn_half(half: &str, lsn: &str) -> Result<u64, PostgresConnectError> {
79 let value = u64::from_str_radix(half, 16)
80 .map_err(|_| PostgresConnectError::Offset(format!("invalid Postgres LSN {lsn:?}")))?;
81
82 if value > 0xffff_ffff {
83 return Err(PostgresConnectError::Offset(format!(
84 "invalid Postgres LSN {lsn:?}: component exceeds 32 bits"
85 )));
86 }
87
88 Ok(value)
89}
90
91#[cfg(test)]
92mod tests {
93 use krabka_connect::{OffsetValue, SourceOffset};
94
95 use super::PgLsn;
96 use crate::PostgresConnectError;
97
98 #[test]
99 fn lsn_round_trips_postgres_text_form() {
100 let lsn: PgLsn = "16/B374D848".parse().expect("valid LSN");
101
102 assert2::assert!(lsn == PgLsn(0x16_b374_d848));
103 assert2::assert!(lsn.to_string() == "16/B374D848".to_owned());
104 assert2::assert!("FFFFFFFF/FFFFFFFF".parse::<PgLsn>().expect("max LSN") == PgLsn(u64::MAX));
105 }
106
107 #[test]
108 fn source_offset_round_trips_and_checks_slot() {
109 let lsn: PgLsn = "0/2A".parse().expect("valid LSN");
110 let offset = lsn.to_source_offset("app", "slot_a");
111
112 assert2::assert!(
113 PgLsn::from_source_offset(&offset, "slot_a").expect("matching slot") == lsn
114 );
115 match PgLsn::from_source_offset(&offset, "slot_b").expect_err("slot mismatch") {
116 PostgresConnectError::Offset(message) => {
117 for needle in ["does not match expected slot", "slot_a", "slot_b"] {
118 assert2::assert!(message.contains(needle));
119 }
120 }
121 error => panic!("expected offset error, got {error:?}"),
122 }
123 }
124
125 #[test]
126 fn source_offset_rejects_missing_or_non_string_slot() {
127 let missing = SourceOffset::default();
128 let mut non_string = PgLsn(42).to_source_offset("app", "slot_a");
129 non_string
130 .partition
131 .0
132 .insert("slot".to_owned(), OffsetValue::Long(7));
133
134 for (_name, offset) in [("missing", missing), ("non_string", non_string)] {
135 match PgLsn::from_source_offset(&offset, "slot_a").expect_err("slot should fail") {
136 PostgresConnectError::Offset(message) => {
137 assert2::assert!(message == "source offset missing string slot");
138 }
139 error => panic!("expected offset error, got {error:?}"),
140 }
141 }
142 }
143
144 #[test]
145 fn malformed_lsn_is_offset_error() {
146 for (_name, input) in [
147 ("missing_separator", "not-a-lsn"),
148 ("invalid_hex", "1/XYZ"),
149 ("component_overflow", "100000000/0"),
150 ] {
151 assert2::assert!(matches!(
152 input.parse::<PgLsn>(),
153 Err(PostgresConnectError::Offset(_))
154 ));
155 }
156 }
157}