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 #[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 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 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
385async 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#[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 fn begin_frame() -> Vec<u8> {
1105 let mut data = vec![b'B'];
1106 data.extend_from_slice(&0u64.to_be_bytes()); data.extend_from_slice(&0i64.to_be_bytes()); data.extend_from_slice(&0u32.to_be_bytes()); 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 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 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 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 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}