Skip to main content

crabka_connect_postgres/
source.rs

1use std::collections::{HashSet, VecDeque};
2
3use async_trait::async_trait;
4use bytes::Bytes;
5use crabka_connect::{ConnectError, ConnectRecord, OffsetValue, Source, SourceOffset};
6
7use crate::catalog::{PgCatalog, TokioPgCatalog};
8use crate::model::Operation;
9use crate::pgoutput::{
10    DecodedMessage, RelationCache, RelationEvent, RowEvent, decode_pgoutput_message,
11};
12use crate::schema::PostgresProtoEncoder;
13use crate::{PgLsn, PostgresSourceConfig};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum LogicalEvent {
17    Begin {
18        final_lsn: PgLsn,
19        xid: i64,
20    },
21    Commit {
22        commit_lsn: PgLsn,
23        end_lsn: PgLsn,
24        commit_timestamp_ms: i64,
25    },
26    Relation(RelationEvent),
27    Row(RowEvent),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31struct TransactionState {
32    xid: i64,
33}
34
35#[derive(Debug)]
36pub struct PostgresWalSource {
37    config: PostgresSourceConfig,
38    database_name: String,
39    catalog: Option<Box<dyn PgCatalog>>,
40    relation_cache: RelationCache,
41    encoder: PostgresProtoEncoder,
42    pending: VecDeque<LogicalEvent>,
43    transaction: Option<TransactionState>,
44    transaction_rows: Vec<RowEvent>,
45    checkpoint: Option<PgLsn>,
46    resume_lsn: Option<PgLsn>,
47}
48
49impl PostgresWalSource {
50    fn build(
51        config: PostgresSourceConfig,
52        database_name: String,
53        catalog: Option<Box<dyn PgCatalog>>,
54        pending: VecDeque<LogicalEvent>,
55    ) -> Result<Self, ConnectError> {
56        Ok(Self {
57            config,
58            database_name,
59            catalog,
60            relation_cache: RelationCache::default(),
61            encoder: PostgresProtoEncoder::new()?,
62            pending,
63            transaction: None,
64            transaction_rows: Vec::new(),
65            checkpoint: None,
66            resume_lsn: None,
67        })
68    }
69
70    pub fn scripted(
71        config: PostgresSourceConfig,
72        database_name: impl Into<String>,
73        events: impl IntoIterator<Item = LogicalEvent>,
74    ) -> Result<Self, ConnectError> {
75        Self::build(
76            config,
77            database_name.into(),
78            None,
79            events.into_iter().collect(),
80        )
81    }
82
83    // cargo-mutants: real DB connection; not exercised under unit tests.
84    #[cfg_attr(test, mutants::skip)]
85    pub async fn connect(config: PostgresSourceConfig) -> Result<Self, ConnectError> {
86        let catalog = TokioPgCatalog::connect(config.database_url.expose_secret()).await?;
87        let database_name = initialize(&catalog, &config).await?;
88        Self::build(
89            config,
90            database_name,
91            Some(Box::new(catalog)),
92            VecDeque::new(),
93        )
94    }
95
96    #[cfg(test)]
97    fn with_catalog(
98        config: PostgresSourceConfig,
99        database_name: impl Into<String>,
100        catalog: Box<dyn PgCatalog>,
101    ) -> Result<Self, ConnectError> {
102        Self::build(config, database_name.into(), Some(catalog), VecDeque::new())
103    }
104
105    #[cfg(test)]
106    fn pending_len(&self) -> usize {
107        self.pending.len()
108    }
109
110    async fn fill_pending_from_slot(&mut self) -> Result<(), ConnectError> {
111        let Some(catalog) = &self.catalog else {
112            return Ok(());
113        };
114
115        // This live seam intentionally peeks instead of getting changes. Peeking
116        // avoids advancing the Postgres replication slot before downstream sink
117        // durability is known; acknowledge advances the slot only after the
118        // runtime has committed the sink and saved the checkpoint.
119        let changes = catalog
120            .peek_changes(
121                &self.config.slot_name,
122                self.config.max_messages_per_poll,
123                &self.config.publication_name,
124            )
125            .await?;
126
127        for change in changes {
128            let lsn = change.lsn.parse::<PgLsn>()?;
129
130            self.apply_decoded_message(decode_pgoutput_message(&change.data, lsn, None)?);
131        }
132
133        Ok(())
134    }
135
136    fn apply_decoded_message(&mut self, message: DecodedMessage) {
137        match message {
138            DecodedMessage::Begin { final_lsn, xid } => {
139                self.apply_logical_event(LogicalEvent::Begin { final_lsn, xid });
140            }
141            DecodedMessage::Commit {
142                commit_lsn,
143                end_lsn,
144                commit_timestamp_ms,
145            } => self.apply_logical_event(LogicalEvent::Commit {
146                commit_lsn,
147                end_lsn,
148                commit_timestamp_ms,
149            }),
150            DecodedMessage::Relation(relation) => {
151                self.apply_logical_event(LogicalEvent::Relation(relation));
152            }
153            DecodedMessage::Row(row) => self.apply_logical_event(LogicalEvent::Row(row)),
154            DecodedMessage::Keepalive => {}
155        }
156    }
157
158    fn apply_logical_event(&mut self, event: LogicalEvent) {
159        match event {
160            LogicalEvent::Begin { final_lsn: _, xid } => {
161                self.transaction = Some(TransactionState { xid });
162                self.transaction_rows.clear();
163            }
164            LogicalEvent::Commit {
165                commit_lsn: _,
166                end_lsn,
167                commit_timestamp_ms,
168            } => {
169                self.commit_transaction(end_lsn, commit_timestamp_ms);
170            }
171            LogicalEvent::Relation(relation) => {
172                self.pending.push_back(LogicalEvent::Relation(relation));
173            }
174            LogicalEvent::Row(row) => {
175                self.enqueue_row(row);
176            }
177        }
178    }
179
180    fn enqueue_row(&mut self, row: RowEvent) {
181        if self.should_skip_row(&row) {
182            return;
183        }
184
185        if self.transaction.is_some() {
186            if !self.transaction_rows.contains(&row) {
187                self.transaction_rows.push(row);
188            }
189        } else {
190            self.pending.push_back(LogicalEvent::Row(row));
191        }
192    }
193
194    fn commit_transaction(&mut self, end_lsn: PgLsn, commit_timestamp_ms: i64) {
195        let Some(transaction) = self.transaction.take() else {
196            return;
197        };
198        if self.should_skip_lsn(end_lsn) {
199            self.transaction_rows.clear();
200            return;
201        }
202
203        for mut row in self.transaction_rows.drain(..) {
204            row.commit_lsn = Some(end_lsn);
205            row.txid = Some(transaction.xid);
206            row.commit_timestamp_ms = Some(commit_timestamp_ms);
207            self.pending.push_back(LogicalEvent::Row(row));
208        }
209    }
210
211    fn should_skip_row(&self, row: &RowEvent) -> bool {
212        let checkpoint_lsn = row.commit_lsn.unwrap_or(row.lsn);
213        if self.should_skip_resume_lsn(checkpoint_lsn) {
214            return true;
215        }
216        if row.commit_lsn.is_some() {
217            return false;
218        }
219
220        self.should_skip_checkpoint_lsn(row.lsn)
221    }
222
223    fn should_skip_lsn(&self, lsn: PgLsn) -> bool {
224        self.should_skip_resume_lsn(lsn) || self.should_skip_checkpoint_lsn(lsn)
225    }
226
227    fn should_skip_resume_lsn(&self, lsn: PgLsn) -> bool {
228        self.resume_lsn.is_some_and(|resume_lsn| lsn <= resume_lsn)
229    }
230
231    fn should_skip_checkpoint_lsn(&self, lsn: PgLsn) -> bool {
232        // Because the live path peeks, Postgres can return rows that were
233        // already emitted and checkpointed in this process. Skip them here so
234        // duplicate peek results do not re-emit before the runtime calls
235        // acknowledge, which advances the replication slot after checkpoint
236        // persistence.
237        self.checkpoint
238            .is_some_and(|checkpoint_lsn| lsn <= checkpoint_lsn)
239    }
240}
241
242#[async_trait]
243impl Source<Bytes, Bytes> for PostgresWalSource {
244    async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
245        if self.pending.is_empty() {
246            self.fill_pending_from_slot().await?;
247        }
248
249        while let Some(event) = self.pending.front().cloned() {
250            match event {
251                LogicalEvent::Begin { .. } | LogicalEvent::Commit { .. } => {
252                    self.pending.pop_front();
253                    self.apply_logical_event(event);
254                }
255                LogicalEvent::Relation(relation) => {
256                    self.pending.pop_front();
257                    self.relation_cache.apply_relation(relation);
258                }
259                LogicalEvent::Row(row) => {
260                    if self.transaction.is_some() && row.commit_lsn.is_none() {
261                        self.pending.pop_front();
262                        self.apply_logical_event(LogicalEvent::Row(row));
263                        continue;
264                    }
265
266                    if self.should_skip_row(&row) {
267                        self.pending.pop_front();
268                        continue;
269                    }
270
271                    let diff = self.relation_cache.translate(row)?;
272                    let key = self.encoder.encode_key(&diff.key)?;
273                    let value = if diff.op == Operation::Delete {
274                        None
275                    } else {
276                        Some(self.encoder.encode_value(&diff)?)
277                    };
278                    self.checkpoint = Some(diff.lsn);
279
280                    let mut record = ConnectRecord::new(Some(key), value)
281                        .with_header("crabka.pg.table", Some(Bytes::from(diff.table.clone())))
282                        .with_header("crabka.pg.lsn", Some(Bytes::from(diff.lsn.to_string())))
283                        .with_header(
284                            "crabka.pg.operation",
285                            Some(Bytes::from_static(operation_header(diff.op).as_bytes())),
286                        );
287                    if let Some(commit_timestamp_ms) = diff.commit_timestamp_ms {
288                        record = record.with_timestamp(commit_timestamp_ms);
289                    }
290
291                    self.pending.pop_front();
292                    return Ok(Some(record));
293                }
294            }
295        }
296
297        Ok(None)
298    }
299
300    fn checkpoint(&self) -> Option<SourceOffset> {
301        self.checkpoint
302            .map(|lsn| lsn.to_source_offset(&self.database_name, &self.config.slot_name))
303    }
304
305    async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
306        validate_database(&offset, &self.database_name)?;
307        let lsn = PgLsn::from_source_offset(&offset, &self.config.slot_name)?;
308        self.checkpoint = Some(lsn);
309        self.resume_lsn = Some(lsn);
310        Ok(())
311    }
312
313    async fn acknowledge(&mut self, offset: &SourceOffset) -> Result<(), ConnectError> {
314        validate_database(offset, &self.database_name)?;
315        let lsn = PgLsn::from_source_offset(offset, &self.config.slot_name)?;
316
317        if let Some(catalog) = &self.catalog {
318            let lsn_text = lsn.to_string();
319            catalog
320                .advance_slot(&self.config.slot_name, &lsn_text)
321                .await?;
322        }
323
324        self.resume_lsn = Some(lsn);
325        Ok(())
326    }
327}
328
329fn quote_ident(value: &str) -> String {
330    format!("\"{}\"", value.replace('"', "\"\""))
331}
332
333fn sql_string(value: &str) -> String {
334    format!("'{}'", value.replace('\'', "''"))
335}
336
337fn create_publication_sql(publication: &str, schema: &str, tables: &[String]) -> String {
338    let table_list = tables
339        .iter()
340        .map(|table| format!("{}.{}", quote_ident(schema), quote_ident(table)))
341        .collect::<Vec<_>>()
342        .join(", ");
343    let create_sql = format!(
344        "CREATE PUBLICATION {} FOR TABLE {} WITH (publish = 'insert, update, delete')",
345        quote_ident(publication),
346        table_list
347    );
348
349    format!(
350        "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = {}) THEN EXECUTE {}; END IF; END $$",
351        sql_string(publication),
352        sql_string(&create_sql)
353    )
354}
355
356pub(crate) fn peek_binary_changes_sql(slot: &str, max_messages: u32, publication: &str) -> String {
357    format!(
358        "SELECT lsn::text AS lsn, data FROM pg_logical_slot_peek_binary_changes({}, NULL, {}, 'proto_version', '1', 'publication_names', {})",
359        sql_string(slot),
360        max_messages,
361        sql_string(publication)
362    )
363}
364
365pub(crate) fn publication_tables_sql() -> &'static str {
366    "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2"
367}
368
369pub(crate) fn publication_settings_sql() -> &'static str {
370    "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1"
371}
372
373pub(crate) fn replication_slot_sql() -> &'static str {
374    "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1"
375}
376
377pub(crate) fn create_logical_slot_sql() -> &'static str {
378    "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
379}
380
381pub(crate) fn advance_slot_sql() -> &'static str {
382    "SELECT pg_replication_slot_advance($1, $2::pg_lsn)"
383}
384
385/// Run the one-time connection setup against `catalog`: resolve the database
386/// name, create + validate the publication (when tables are configured), and
387/// ensure the replication slot. Split out of [`PostgresWalSource::connect`] so
388/// the orchestration is unit-testable against a [`PgCatalog`] mock.
389async fn initialize(
390    catalog: &dyn PgCatalog,
391    config: &PostgresSourceConfig,
392) -> Result<String, ConnectError> {
393    let database_name = catalog.current_database().await?;
394
395    if !config.table_names.is_empty() {
396        catalog
397            .ensure_publication(&create_publication_sql(
398                &config.publication_name,
399                &config.schema,
400                &config.table_names,
401            ))
402            .await?;
403        validate_publication_tables(
404            catalog,
405            &config.publication_name,
406            &config.schema,
407            &config.table_names,
408        )
409        .await?;
410        validate_publication_settings(catalog, &config.publication_name).await?;
411    }
412
413    ensure_slot(catalog, &config.slot_name, &database_name).await?;
414
415    Ok(database_name)
416}
417
418async fn validate_publication_tables(
419    catalog: &dyn PgCatalog,
420    publication: &str,
421    schema: &str,
422    tables: &[String],
423) -> Result<(), ConnectError> {
424    let published_tables = catalog.published_tables(publication, schema).await?;
425    let missing_tables = missing_publication_tables(tables, published_tables);
426
427    if missing_tables.is_empty() {
428        Ok(())
429    } else {
430        Err(ConnectError::Backend(format!(
431            "publication {publication:?} does not cover configured tables: {}",
432            missing_tables.join(", ")
433        )))
434    }
435}
436
437fn missing_publication_tables(
438    tables: &[String],
439    published_tables: impl IntoIterator<Item = String>,
440) -> Vec<String> {
441    let published_tables = published_tables.into_iter().collect::<HashSet<_>>();
442    tables
443        .iter()
444        .filter(|table| !published_tables.contains(*table))
445        .cloned()
446        .collect()
447}
448
449async fn validate_publication_settings(
450    catalog: &dyn PgCatalog,
451    publication: &str,
452) -> Result<(), ConnectError> {
453    let Some(flags) = catalog.publication_settings(publication).await? else {
454        return Ok(());
455    };
456
457    if publication_settings_are_compatible(flags) {
458        Ok(())
459    } else {
460        Err(ConnectError::Backend(format!(
461            "publication {publication:?} must publish insert, update, and delete, and must not publish truncate"
462        )))
463    }
464}
465
466fn publication_settings_are_compatible([insert, update, delete, truncate]: [bool; 4]) -> bool {
467    insert && update && delete && !truncate
468}
469
470async fn ensure_slot(
471    catalog: &dyn PgCatalog,
472    slot_name: &str,
473    database_name: &str,
474) -> Result<(), ConnectError> {
475    let Some(slot) = catalog.replication_slot(slot_name).await? else {
476        catalog.create_logical_slot(slot_name).await?;
477        return Ok(());
478    };
479
480    validate_slot_metadata(
481        slot_name,
482        slot.plugin.as_deref(),
483        &slot.slot_type,
484        slot.database.as_deref(),
485        database_name,
486    )
487}
488
489fn validate_slot_metadata(
490    slot_name: &str,
491    plugin: Option<&str>,
492    slot_type: &str,
493    database: Option<&str>,
494    database_name: &str,
495) -> Result<(), ConnectError> {
496    let mut mismatches = Vec::new();
497
498    if plugin != Some("pgoutput") {
499        mismatches.push(format!("plugin is {plugin:?}"));
500    }
501    if slot_type != "logical" {
502        mismatches.push(format!("slot_type is {slot_type:?}"));
503    }
504    if database != Some(database_name) {
505        mismatches.push(format!("database is {database:?}"));
506    }
507
508    if mismatches.is_empty() {
509        Ok(())
510    } else {
511        Err(ConnectError::Backend(format!(
512            "replication slot {slot_name:?} is not compatible: {}",
513            mismatches.join(", ")
514        )))
515    }
516}
517
518fn validate_database(offset: &SourceOffset, expected_database: &str) -> Result<(), ConnectError> {
519    match offset.partition.get("database") {
520        Some(OffsetValue::String(database)) if database == expected_database => Ok(()),
521        Some(OffsetValue::String(database)) => Err(ConnectError::Offset(format!(
522            "source offset database {database:?} does not match expected database {expected_database:?}"
523        ))),
524        _ => Err(ConnectError::Offset(
525            "source offset missing string database".to_owned(),
526        )),
527    }
528}
529
530fn operation_header(operation: Operation) -> &'static str {
531    match operation {
532        Operation::Insert => "insert",
533        Operation::Update => "update",
534        Operation::Delete => "delete",
535    }
536}
537
538#[cfg(test)]
539mod sql_tests {
540    use assert2::check;
541
542    use super::{
543        advance_slot_sql, create_logical_slot_sql, create_publication_sql,
544        missing_publication_tables, peek_binary_changes_sql, publication_settings_are_compatible,
545        publication_settings_sql, publication_tables_sql, replication_slot_sql,
546        validate_slot_metadata,
547    };
548
549    #[test]
550    fn create_logical_slot_sql_uses_pgoutput_plugin() {
551        check!(
552            create_logical_slot_sql()
553                == "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
554        );
555    }
556
557    #[test]
558    fn publication_sql_quotes_identifiers() {
559        let sql = create_publication_sql(
560            "pub\"name",
561            "sch\"ema",
562            &["orders".to_owned(), "line\"items".to_owned()],
563        );
564
565        check!(sql.contains("CREATE PUBLICATION \"pub\"\"name\""));
566        check!(
567            sql.contains("FOR TABLE \"sch\"\"ema\".\"orders\", \"sch\"\"ema\".\"line\"\"items\"")
568        );
569        check!(sql.contains("WHERE pubname = 'pub\"name'"));
570    }
571
572    #[test]
573    fn peek_binary_changes_sql_escapes_string_literals_without_advancing_slot() {
574        let sql = peek_binary_changes_sql("slot'name", 25, "pub'name");
575
576        check!(
577            sql == "SELECT lsn::text AS lsn, data FROM pg_logical_slot_peek_binary_changes('slot''name', NULL, 25, 'proto_version', '1', 'publication_names', 'pub''name')"
578        );
579    }
580
581    #[test]
582    fn publication_sql_excludes_truncate_events() {
583        let sql = create_publication_sql("pub", "public", &["orders".to_owned()]);
584
585        check!(sql.contains("WITH (publish = ''insert, update, delete'')"));
586    }
587
588    #[test]
589    fn publication_validation_sql_reads_publication_tables() {
590        check!(
591            publication_tables_sql()
592                == "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2"
593        );
594    }
595
596    #[test]
597    fn publication_settings_sql_reads_publish_flags() {
598        check!(
599            publication_settings_sql()
600                == "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1"
601        );
602    }
603
604    #[test]
605    fn replication_slot_validation_sql_reads_slot_metadata() {
606        check!(
607            replication_slot_sql()
608                == "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1"
609        );
610    }
611
612    #[test]
613    fn advance_slot_sql_casts_lsn_parameter() {
614        check!(advance_slot_sql() == "SELECT pg_replication_slot_advance($1, $2::pg_lsn)");
615    }
616
617    #[test]
618    fn publication_table_validation_reports_only_missing_configured_tables() {
619        let missing = missing_publication_tables(
620            &["orders".to_owned(), "accounts".to_owned()],
621            ["orders".to_owned(), "ignored".to_owned()],
622        );
623
624        check!(missing == vec!["accounts".to_owned()]);
625    }
626
627    #[test]
628    fn publication_settings_require_insert_update_delete_without_truncate() {
629        check!(publication_settings_are_compatible([
630            true, true, true, false
631        ]));
632        check!(!publication_settings_are_compatible([
633            false, true, true, false
634        ]));
635        check!(!publication_settings_are_compatible([
636            true, false, true, false
637        ]));
638        check!(!publication_settings_are_compatible([
639            true, true, false, false
640        ]));
641        check!(!publication_settings_are_compatible([
642            true, true, true, true
643        ]));
644    }
645
646    #[test]
647    fn slot_metadata_accepts_pgoutput_logical_slot_for_current_database() {
648        validate_slot_metadata("slot_a", Some("pgoutput"), "logical", Some("app"), "app")
649            .expect("matching slot should validate");
650    }
651
652    #[test]
653    fn slot_metadata_reports_all_incompatible_fields() {
654        let error =
655            validate_slot_metadata("slot_a", Some("test_decoding"), "physical", None, "app")
656                .expect_err("incompatible slot should fail");
657
658        match error {
659            crabka_connect::ConnectError::Backend(message) => {
660                check!(message.contains("replication slot \"slot_a\" is not compatible"));
661                check!(message.contains("plugin is Some(\"test_decoding\")"));
662                check!(message.contains("slot_type is \"physical\""));
663                check!(message.contains("database is None"));
664            }
665            error => panic!("expected backend error, got {error:?}"),
666        }
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use assert2::check;
673    use crabka_connect::{SecretString, Source as _};
674    use crabka_schema_serde::wire::MAGIC;
675
676    use super::{LogicalEvent, PostgresWalSource, validate_database};
677    use crate::model::{ColumnSchema, ColumnValue, ScalarValue};
678    use crate::pgoutput::{RelationEvent, RowEvent, RowEventKind, RowTupleKind};
679    use crate::{PgLsn, PostgresSourceConfig};
680
681    fn header_value(
682        record: &crabka_connect::ConnectRecord<bytes::Bytes, bytes::Bytes>,
683        key: &str,
684    ) -> bytes::Bytes {
685        record
686            .headers
687            .iter()
688            .find(|header| header.key == key)
689            .and_then(|header| header.value.clone())
690            .unwrap_or_else(|| panic!("missing header {key}"))
691    }
692
693    fn config(slot_name: &str) -> PostgresSourceConfig {
694        PostgresSourceConfig {
695            database_url: SecretString::new("postgres://localhost/app"),
696            slot_name: slot_name.to_owned(),
697            publication_name: "crabka_connect".to_owned(),
698            schema: "public".to_owned(),
699            table_names: vec!["orders".to_owned()],
700            max_messages_per_poll: 1000,
701        }
702    }
703
704    fn orders_relation() -> RelationEvent {
705        RelationEvent {
706            relation_id: 7,
707            schema: "public".to_owned(),
708            table: "orders".to_owned(),
709            columns: vec![
710                ColumnSchema {
711                    name: "id".to_owned(),
712                    type_name: "int8".to_owned(),
713                    key: true,
714                },
715                ColumnSchema {
716                    name: "status".to_owned(),
717                    type_name: "text".to_owned(),
718                    key: false,
719                },
720            ],
721        }
722    }
723
724    fn id(value: i64) -> ColumnValue {
725        ColumnValue {
726            name: "id".to_owned(),
727            value: ScalarValue::Int(value),
728        }
729    }
730
731    fn status(value: &str) -> ColumnValue {
732        ColumnValue {
733            name: "status".to_owned(),
734            value: ScalarValue::Text(value.to_owned()),
735        }
736    }
737
738    fn insert_event(lsn: PgLsn) -> RowEvent {
739        RowEvent {
740            relation_id: 7,
741            lsn,
742            commit_lsn: None,
743            txid: Some(99),
744            commit_timestamp_ms: Some(1_700_000_000_000),
745            kind: RowEventKind::Insert,
746            values: vec![id(42), status("paid")],
747        }
748    }
749
750    fn delete_event(lsn: PgLsn) -> RowEvent {
751        RowEvent {
752            relation_id: 7,
753            lsn,
754            commit_lsn: None,
755            txid: None,
756            commit_timestamp_ms: None,
757            kind: RowEventKind::Delete {
758                tuple_kind: RowTupleKind::Full,
759            },
760            values: vec![id(42), status("cancelled")],
761        }
762    }
763
764    #[tokio::test]
765    async fn insert_poll_emits_framed_record_and_checkpoint() {
766        let mut source = PostgresWalSource::scripted(
767            config("slot_a"),
768            "app",
769            [
770                LogicalEvent::Relation(orders_relation()),
771                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
772            ],
773        )
774        .expect("source builds");
775
776        let record = source
777            .poll()
778            .await
779            .expect("poll succeeds")
780            .expect("row emits");
781
782        check!(record.key.is_some());
783        check!(record.value.is_some());
784        check!(record.key.as_ref().expect("key")[0] == MAGIC);
785        check!(record.value.as_ref().expect("value")[0] == MAGIC);
786        check!(record.timestamp == Some(1_700_000_000_000));
787        check!(header_value(&record, "crabka.pg.table").as_ref() == b"public.orders");
788        check!(header_value(&record, "crabka.pg.lsn").as_ref() == b"0/2A");
789        check!(header_value(&record, "crabka.pg.operation").as_ref() == b"insert");
790        check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
791    }
792
793    #[tokio::test]
794    async fn delete_poll_emits_tombstone() {
795        let mut source = PostgresWalSource::scripted(
796            config("slot_a"),
797            "app",
798            [
799                LogicalEvent::Relation(orders_relation()),
800                LogicalEvent::Row(delete_event(PgLsn(0x2b))),
801            ],
802        )
803        .expect("source builds");
804
805        let record = source
806            .poll()
807            .await
808            .expect("poll succeeds")
809            .expect("row emits");
810
811        check!(record.key.is_some());
812        check!(record.value.is_none());
813        check!(record.timestamp.is_none());
814        check!(header_value(&record, "crabka.pg.table").as_ref() == b"public.orders");
815        check!(header_value(&record, "crabka.pg.lsn").as_ref() == b"0/2B");
816        check!(header_value(&record, "crabka.pg.operation").as_ref() == b"delete");
817    }
818
819    #[tokio::test]
820    async fn seek_restores_lsn_checkpoint() {
821        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
822        let offset = PgLsn(0x2a).to_source_offset("app", "slot_a");
823
824        source.seek(offset).await.expect("seek succeeds");
825
826        check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
827    }
828
829    #[test]
830    fn skip_lsn_checks_resume_and_checkpoint_offsets_independently() {
831        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
832        source.resume_lsn = Some(PgLsn(0x20));
833
834        check!(source.should_skip_lsn(PgLsn(0x20)));
835        check!(!source.should_skip_lsn(PgLsn(0x21)));
836
837        source.resume_lsn = None;
838        source.checkpoint = Some(PgLsn(0x30));
839
840        check!(source.should_skip_lsn(PgLsn(0x30)));
841        check!(!source.should_skip_lsn(PgLsn(0x31)));
842
843        source.resume_lsn = Some(PgLsn(0x20));
844        source.checkpoint = Some(PgLsn(0x30));
845
846        check!(source.should_skip_lsn(PgLsn(0x20)));
847        check!(source.should_skip_lsn(PgLsn(0x30)));
848        check!(!source.should_skip_lsn(PgLsn(0x31)));
849    }
850
851    #[tokio::test]
852    async fn seek_past_first_row_poll_emits_only_later_row_without_regressing_checkpoint() {
853        let mut source = PostgresWalSource::scripted(
854            config("slot_a"),
855            "app",
856            [
857                LogicalEvent::Relation(orders_relation()),
858                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
859                LogicalEvent::Row(insert_event(PgLsn(0x2b))),
860            ],
861        )
862        .expect("source builds");
863
864        source
865            .seek(PgLsn(0x2a).to_source_offset("app", "slot_a"))
866            .await
867            .expect("seek succeeds");
868
869        let record = source
870            .poll()
871            .await
872            .expect("poll succeeds")
873            .expect("later row emits");
874
875        check!(header_value(&record, "crabka.pg.lsn").as_ref() == b"0/2B");
876        check!(source.checkpoint() == Some(PgLsn(0x2b).to_source_offset("app", "slot_a")));
877        check!(source.poll().await.expect("poll succeeds").is_none());
878    }
879
880    #[tokio::test]
881    async fn checkpointed_duplicate_row_is_skipped_without_regressing_checkpoint() {
882        let mut source = PostgresWalSource::scripted(
883            config("slot_a"),
884            "app",
885            [
886                LogicalEvent::Relation(orders_relation()),
887                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
888                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
889            ],
890        )
891        .expect("source builds");
892
893        let record = source
894            .poll()
895            .await
896            .expect("poll succeeds")
897            .expect("first row emits");
898
899        check!(header_value(&record, "crabka.pg.lsn").as_ref() == b"0/2A");
900        check!(source.poll().await.expect("poll succeeds").is_none());
901        check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
902    }
903
904    #[tokio::test]
905    async fn translation_failure_does_not_drop_pending_row() {
906        let mut source = PostgresWalSource::scripted(
907            config("slot_a"),
908            "app",
909            [LogicalEvent::Row(insert_event(PgLsn(0x2a)))],
910        )
911        .expect("source builds");
912
913        check!(source.poll().await.is_err());
914
915        check!(source.pending_len() == 1);
916        check!(source.checkpoint().is_none());
917    }
918
919    #[tokio::test]
920    async fn seek_rejects_offset_for_different_database() {
921        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
922        let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
923
924        let error = source.seek(offset).await.expect_err("database mismatch");
925
926        check!(matches!(error, crabka_connect::ConnectError::Offset(_)));
927        check!(source.checkpoint().is_none());
928    }
929
930    #[test]
931    fn validate_database_rejects_missing_or_non_string_database_partition() {
932        let missing = crabka_connect::SourceOffset::default();
933        let mut non_string = crabka_connect::SourceOffset::default();
934        non_string
935            .partition
936            .insert("database".to_owned(), crabka_connect::OffsetValue::Long(7));
937
938        check!(matches!(
939            validate_database(&missing, "app"),
940            Err(crabka_connect::ConnectError::Offset(_))
941        ));
942        check!(matches!(
943            validate_database(&non_string, "app"),
944            Err(crabka_connect::ConnectError::Offset(_))
945        ));
946    }
947
948    #[test]
949    fn validate_database_reports_database_mismatch() {
950        let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
951
952        let error = validate_database(&offset, "app").expect_err("database mismatch should fail");
953
954        match error {
955            crabka_connect::ConnectError::Offset(message) => {
956                check!(message.contains("does not match expected database"));
957                check!(message.contains("other_app"));
958                check!(message.contains("app"));
959            }
960            error => panic!("expected offset error, got {error:?}"),
961        }
962    }
963
964    #[tokio::test]
965    async fn scripted_acknowledge_accepts_matching_offset_and_rejects_database_mismatch() {
966        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
967
968        source
969            .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
970            .await
971            .expect("matching offset acknowledged");
972
973        let error = source
974            .acknowledge(&PgLsn(0x2b).to_source_offset("other_app", "slot_a"))
975            .await
976            .expect_err("database mismatch rejected");
977
978        check!(matches!(error, crabka_connect::ConnectError::Offset(_)));
979    }
980
981    #[tokio::test]
982    async fn relation_only_poll_returns_none_without_checkpoint() {
983        let mut source = PostgresWalSource::scripted(
984            config("slot_a"),
985            "app",
986            [LogicalEvent::Relation(orders_relation())],
987        )
988        .expect("source builds");
989
990        check!(source.poll().await.expect("poll succeeds").is_none());
991        check!(source.checkpoint().is_none());
992    }
993
994    #[tokio::test]
995    async fn transaction_commit_metadata_is_applied_to_all_rows() {
996        let mut second = insert_event(PgLsn(0x2b));
997        second.values = vec![id(43), status("pending")];
998        let mut source = PostgresWalSource::scripted(
999            config("slot_a"),
1000            "app",
1001            [
1002                LogicalEvent::Relation(orders_relation()),
1003                LogicalEvent::Begin {
1004                    final_lsn: PgLsn(0x40),
1005                    xid: 123,
1006                },
1007                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1008                LogicalEvent::Row(second),
1009                LogicalEvent::Commit {
1010                    commit_lsn: PgLsn(0x41),
1011                    end_lsn: PgLsn(0x42),
1012                    commit_timestamp_ms: 1_700_000_000_123,
1013                },
1014            ],
1015        )
1016        .expect("source builds");
1017
1018        let first = source
1019            .poll()
1020            .await
1021            .expect("first poll succeeds")
1022            .expect("first row emits");
1023        let second = source
1024            .poll()
1025            .await
1026            .expect("second poll succeeds")
1027            .expect("second row emits");
1028
1029        check!(header_value(&first, "crabka.pg.lsn").as_ref() == b"0/42");
1030        check!(header_value(&second, "crabka.pg.lsn").as_ref() == b"0/42");
1031        check!(first.timestamp == Some(1_700_000_000_123));
1032        check!(second.timestamp == Some(1_700_000_000_123));
1033        check!(source.checkpoint() == Some(PgLsn(0x42).to_source_offset("app", "slot_a")));
1034        check!(source.poll().await.expect("poll succeeds").is_none());
1035    }
1036
1037    #[test]
1038    fn decoded_transaction_messages_stage_rows_with_commit_metadata() {
1039        let mut source =
1040            PostgresWalSource::scripted(config("slot_a"), "app", []).expect("source builds");
1041        let mut row = insert_event(PgLsn(0x2a));
1042        row.txid = None;
1043        row.commit_timestamp_ms = None;
1044
1045        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Begin {
1046            final_lsn: PgLsn(0x40),
1047            xid: 123,
1048        });
1049        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Row(row));
1050        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Commit {
1051            commit_lsn: PgLsn(0x41),
1052            end_lsn: PgLsn(0x42),
1053            commit_timestamp_ms: 1_700_000_000_123,
1054        });
1055
1056        let Some(LogicalEvent::Row(row)) = source.pending.pop_front() else {
1057            panic!("committed row should be pending");
1058        };
1059        check!(row.lsn == PgLsn(0x2a));
1060        check!(row.commit_lsn == Some(PgLsn(0x42)));
1061        check!(row.txid == Some(123));
1062        check!(row.commit_timestamp_ms == Some(1_700_000_000_123));
1063    }
1064}
1065
1066/// Mock-driven coverage for the connection-setup decision logic. The `PgCatalog`
1067/// seam lets these run entirely offline: every excluded-by-necessity live query
1068/// is replaced by a `mockall` expectation, so the validation/orchestration
1069/// branches carry real mutation signal without a running `PostgreSQL`.
1070#[cfg(test)]
1071mod catalog_tests {
1072    use assert2::check;
1073    use crabka_connect::{ConnectError, SecretString, Source as _};
1074
1075    use super::{
1076        PostgresWalSource, ensure_slot, initialize, validate_publication_settings,
1077        validate_publication_tables,
1078    };
1079    use crate::catalog::{MockPgCatalog, SlotChange, SlotMetadata};
1080    use crate::{PgLsn, PostgresSourceConfig};
1081
1082    fn config_with_tables(tables: Vec<String>) -> PostgresSourceConfig {
1083        PostgresSourceConfig {
1084            database_url: SecretString::new("postgres://localhost/app"),
1085            slot_name: "slot_a".to_owned(),
1086            publication_name: "crabka_connect".to_owned(),
1087            schema: "public".to_owned(),
1088            table_names: tables,
1089            max_messages_per_poll: 1000,
1090        }
1091    }
1092
1093    fn valid_slot() -> SlotMetadata {
1094        SlotMetadata {
1095            plugin: Some("pgoutput".to_owned()),
1096            slot_type: "logical".to_owned(),
1097            database: Some("app".to_owned()),
1098        }
1099    }
1100
1101    /// A minimal valid pgoutput `Begin` frame (tag, then the final-LSN,
1102    /// commit-time, and xid fields); decoding it stages a transaction without
1103    /// needing real WAL bytes.
1104    fn begin_frame() -> Vec<u8> {
1105        let mut data = vec![b'B'];
1106        data.extend_from_slice(&0u64.to_be_bytes()); // final_lsn
1107        data.extend_from_slice(&0i64.to_be_bytes()); // commit_time
1108        data.extend_from_slice(&0u32.to_be_bytes()); // xid
1109        data
1110    }
1111
1112    #[tokio::test]
1113    async fn validate_publication_tables_accepts_full_coverage_and_reports_gaps() {
1114        let mut catalog = MockPgCatalog::new();
1115        catalog
1116            .expect_published_tables()
1117            .returning(|_, _| Ok(vec!["orders".to_owned()]));
1118
1119        validate_publication_tables(&catalog, "crabka_connect", "public", &["orders".to_owned()])
1120            .await
1121            .expect("full coverage validates");
1122
1123        let mut missing = MockPgCatalog::new();
1124        missing
1125            .expect_published_tables()
1126            .returning(|_, _| Ok(Vec::new()));
1127
1128        let error = validate_publication_tables(
1129            &missing,
1130            "crabka_connect",
1131            "public",
1132            &["orders".to_owned()],
1133        )
1134        .await
1135        .expect_err("uncovered table fails");
1136        match error {
1137            ConnectError::Backend(message) => {
1138                check!(message.contains("does not cover configured tables"));
1139                check!(message.contains("orders"));
1140            }
1141            error => panic!("expected backend error, got {error:?}"),
1142        }
1143    }
1144
1145    #[tokio::test]
1146    async fn validate_publication_settings_requires_insert_update_delete_without_truncate() {
1147        let mut compatible = MockPgCatalog::new();
1148        compatible
1149            .expect_publication_settings()
1150            .returning(|_| Ok(Some([true, true, true, false])));
1151        validate_publication_settings(&compatible, "crabka_connect")
1152            .await
1153            .expect("compatible flags validate");
1154
1155        let mut truncating = MockPgCatalog::new();
1156        truncating
1157            .expect_publication_settings()
1158            .returning(|_| Ok(Some([true, true, true, true])));
1159        let error = validate_publication_settings(&truncating, "crabka_connect")
1160            .await
1161            .expect_err("publishing truncate fails");
1162        check!(matches!(error, ConnectError::Backend(_)));
1163
1164        // An absent publication row is tolerated (the create path handles it).
1165        let mut absent = MockPgCatalog::new();
1166        absent.expect_publication_settings().returning(|_| Ok(None));
1167        validate_publication_settings(&absent, "crabka_connect")
1168            .await
1169            .expect("missing publication row is tolerated");
1170    }
1171
1172    #[tokio::test]
1173    async fn ensure_slot_creates_when_absent_and_validates_when_present() {
1174        let mut absent = MockPgCatalog::new();
1175        absent.expect_replication_slot().returning(|_| Ok(None));
1176        absent
1177            .expect_create_logical_slot()
1178            .times(1)
1179            .returning(|_| Ok(()));
1180        ensure_slot(&absent, "slot_a", "app")
1181            .await
1182            .expect("absent slot is created");
1183
1184        // A present, compatible slot must not be recreated.
1185        let mut present = MockPgCatalog::new();
1186        present
1187            .expect_replication_slot()
1188            .returning(|_| Ok(Some(valid_slot())));
1189        ensure_slot(&present, "slot_a", "app")
1190            .await
1191            .expect("compatible slot validates");
1192
1193        let mut mismatched = MockPgCatalog::new();
1194        mismatched.expect_replication_slot().returning(|_| {
1195            Ok(Some(SlotMetadata {
1196                plugin: Some("test_decoding".to_owned()),
1197                slot_type: "physical".to_owned(),
1198                database: Some("other".to_owned()),
1199            }))
1200        });
1201        let error = ensure_slot(&mismatched, "slot_a", "app")
1202            .await
1203            .expect_err("incompatible slot fails");
1204        check!(matches!(error, ConnectError::Backend(_)));
1205    }
1206
1207    #[tokio::test]
1208    async fn initialize_runs_publication_setup_only_when_tables_configured() {
1209        let mut with_tables = MockPgCatalog::new();
1210        with_tables
1211            .expect_current_database()
1212            .returning(|| Ok("app".to_owned()));
1213        with_tables
1214            .expect_ensure_publication()
1215            .times(1)
1216            .returning(|_| Ok(()));
1217        with_tables
1218            .expect_published_tables()
1219            .returning(|_, _| Ok(vec!["orders".to_owned()]));
1220        with_tables
1221            .expect_publication_settings()
1222            .returning(|_| Ok(Some([true, true, true, false])));
1223        with_tables
1224            .expect_replication_slot()
1225            .returning(|_| Ok(Some(valid_slot())));
1226
1227        let database = initialize(&with_tables, &config_with_tables(vec!["orders".to_owned()]))
1228            .await
1229            .expect("initialize succeeds");
1230        check!(database == "app");
1231
1232        // With no tables configured, the publication path is skipped entirely
1233        // (no `ensure_publication` expectation set — calling it would panic).
1234        let mut no_tables = MockPgCatalog::new();
1235        no_tables
1236            .expect_current_database()
1237            .returning(|| Ok("app".to_owned()));
1238        no_tables
1239            .expect_replication_slot()
1240            .returning(|_| Ok(Some(valid_slot())));
1241        initialize(&no_tables, &config_with_tables(Vec::new()))
1242            .await
1243            .expect("initialize without tables succeeds");
1244    }
1245
1246    #[tokio::test]
1247    async fn fill_pending_applies_peeked_changes_and_propagates_decode_input() {
1248        let mut catalog = MockPgCatalog::new();
1249        catalog.expect_peek_changes().returning(|_, _, _| {
1250            Ok(vec![SlotChange {
1251                lsn: "0/2A".to_owned(),
1252                data: begin_frame(),
1253            }])
1254        });
1255
1256        let mut source = PostgresWalSource::with_catalog(
1257            config_with_tables(Vec::new()),
1258            "app",
1259            Box::new(catalog),
1260        )
1261        .expect("source builds");
1262        source
1263            .fill_pending_from_slot()
1264            .await
1265            .expect("fill succeeds");
1266        // The Begin frame opened a transaction — proof the peeked change was
1267        // decoded and applied rather than dropped.
1268        check!(source.transaction.is_some());
1269    }
1270
1271    #[tokio::test]
1272    async fn fill_pending_surfaces_unparsable_lsn() {
1273        let mut catalog = MockPgCatalog::new();
1274        catalog.expect_peek_changes().returning(|_, _, _| {
1275            Ok(vec![SlotChange {
1276                lsn: "not-a-lsn".to_owned(),
1277                data: begin_frame(),
1278            }])
1279        });
1280
1281        let mut source = PostgresWalSource::with_catalog(
1282            config_with_tables(Vec::new()),
1283            "app",
1284            Box::new(catalog),
1285        )
1286        .expect("source builds");
1287        check!(source.fill_pending_from_slot().await.is_err());
1288    }
1289
1290    #[tokio::test]
1291    async fn fill_pending_without_catalog_is_a_noop() {
1292        let mut source =
1293            PostgresWalSource::scripted(config_with_tables(Vec::new()), "app", []).unwrap();
1294        source
1295            .fill_pending_from_slot()
1296            .await
1297            .expect("noop succeeds");
1298        check!(source.pending_len() == 0);
1299    }
1300
1301    #[tokio::test]
1302    async fn acknowledge_advances_slot_through_catalog() {
1303        let mut catalog = MockPgCatalog::new();
1304        catalog
1305            .expect_advance_slot()
1306            .times(1)
1307            .returning(|_, _| Ok(()));
1308
1309        let mut source = PostgresWalSource::with_catalog(
1310            config_with_tables(Vec::new()),
1311            "app",
1312            Box::new(catalog),
1313        )
1314        .expect("source builds");
1315        source
1316            .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
1317            .await
1318            .expect("acknowledge advances the slot");
1319    }
1320}