1use std::collections::{HashMap, HashSet, VecDeque};
2
3use async_trait::async_trait;
4use bytes::Bytes;
5use indexmap::IndexSet;
6use krabka_connect::{ConnectError, ConnectRecord, OffsetValue, Source, SourceOffset};
7
8use crate::{
9 PgLsn, PostgresSourceConfig,
10 catalog::{PgCatalog, TokioPgCatalog},
11 ids::{CommitLsn, EndLsn, TransactionId},
12 model::Operation,
13 pgoutput::{DecodedMessage, RelationCache, RelationEvent, RowEvent, decode_pgoutput_message},
14 schema::PostgresProtoEncoder,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum LogicalEvent {
19 Begin {
20 final_lsn: PgLsn,
21 xid: TransactionId,
22 },
23 Commit {
24 commit_lsn: CommitLsn,
25 end_lsn: EndLsn,
26 commit_timestamp_ms: i64,
27 },
28 Relation(RelationEvent),
29 Row(RowEvent),
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33struct TransactionState {
34 xid: TransactionId,
35}
36
37#[derive(Debug)]
38pub struct PostgresWalSource {
39 config: PostgresSourceConfig,
40 database_name: String,
41 catalog: Option<Box<dyn PgCatalog>>,
42 relation_cache: RelationCache,
43 encoder: PostgresProtoEncoder,
44 pending: VecDeque<LogicalEvent>,
45 remaining_transaction_rows: HashMap<PgLsn, usize>,
46 transaction: Option<TransactionState>,
47 transaction_rows: IndexSet<RowEvent>,
48 checkpoint: Option<PgLsn>,
49 resume_lsn: Option<PgLsn>,
50 #[cfg(test)]
51 transaction_boundary_checks: usize,
52}
53
54impl PostgresWalSource {
55 fn build(
56 config: PostgresSourceConfig,
57 database_name: String,
58 catalog: Option<Box<dyn PgCatalog>>,
59 pending: VecDeque<LogicalEvent>,
60 encoder: PostgresProtoEncoder,
61 ) -> Self {
62 let remaining_transaction_rows =
63 pending
64 .iter()
65 .fold(HashMap::<PgLsn, usize>::new(), |mut remaining, event| {
66 if let LogicalEvent::Row(RowEvent {
67 commit_lsn: Some(commit_lsn),
68 ..
69 }) = event
70 {
71 *remaining.entry(*commit_lsn).or_default() += 1;
72 }
73 remaining
74 });
75 Self {
76 config,
77 database_name,
78 catalog,
79 relation_cache: RelationCache::default(),
80 encoder,
81 pending,
82 remaining_transaction_rows,
83 transaction: None,
84 transaction_rows: IndexSet::new(),
85 checkpoint: None,
86 resume_lsn: None,
87 #[cfg(test)]
88 transaction_boundary_checks: 0,
89 }
90 }
91
92 #[cfg(test)]
95 pub fn scripted(
96 config: PostgresSourceConfig,
97 database_name: impl Into<String>,
98 events: impl IntoIterator<Item = LogicalEvent>,
99 ) -> Result<Self, ConnectError> {
100 Ok(Self::build(
101 config,
102 database_name.into(),
103 None,
104 events.into_iter().collect(),
105 PostgresProtoEncoder::for_test()?,
106 ))
107 }
108
109 #[cfg_attr(test, mutants::skip)]
111 #[tracing::instrument(
112 level = "info",
113 skip_all,
114 fields(slot = %config.slot_name, publication = %config.publication_name, schema = %config.schema),
115 err,
116 )]
117 pub async fn connect(config: PostgresSourceConfig) -> Result<Self, ConnectError> {
120 let encoder = PostgresProtoEncoder::from_registry(&config.schema_registry_url).await?;
121 let catalog = TokioPgCatalog::connect(config.database_url.expose_secret()).await?;
122 let database_name = initialize(&catalog, &config).await?;
123 Ok(Self::build(
124 config,
125 database_name,
126 Some(Box::new(catalog)),
127 VecDeque::new(),
128 encoder,
129 ))
130 }
131
132 #[cfg(test)]
133 fn with_catalog(
134 config: PostgresSourceConfig,
135 database_name: impl Into<String>,
136 catalog: Box<dyn PgCatalog>,
137 ) -> Result<Self, ConnectError> {
138 Ok(Self::build(
139 config,
140 database_name.into(),
141 Some(catalog),
142 VecDeque::new(),
143 PostgresProtoEncoder::for_test()?,
144 ))
145 }
146
147 #[cfg(test)]
148 fn pending_len(&self) -> usize {
149 self.pending.len()
150 }
151
152 #[tracing::instrument(
153 level = "debug",
154 skip_all,
155 fields(slot = %self.config.slot_name, changes = tracing::field::Empty),
156 err,
157 )]
158 async fn fill_pending_from_slot(&mut self) -> Result<(), ConnectError> {
159 let Some(catalog) = &self.catalog else {
160 return Ok(());
161 };
162
163 let changes = catalog
168 .peek_changes(
169 &self.config.slot_name,
170 self.config.max_messages_per_poll,
171 &self.config.publication_name,
172 )
173 .await?;
174
175 tracing::Span::current().record("changes", changes.len());
176 for change in changes {
177 let lsn = change.lsn.parse::<PgLsn>()?;
178
179 self.apply_decoded_message(decode_pgoutput_message(&change.data, lsn, None)?);
180 }
181
182 Ok(())
183 }
184
185 fn apply_decoded_message(&mut self, message: DecodedMessage) {
186 match message {
187 DecodedMessage::Begin { final_lsn, xid } => {
188 self.apply_logical_event(LogicalEvent::Begin { final_lsn, xid });
189 }
190 DecodedMessage::Commit {
191 commit_lsn,
192 end_lsn,
193 commit_timestamp_ms,
194 } => self.apply_logical_event(LogicalEvent::Commit {
195 commit_lsn,
196 end_lsn,
197 commit_timestamp_ms,
198 }),
199 DecodedMessage::Relation(relation) => {
200 self.apply_logical_event(LogicalEvent::Relation(relation));
201 }
202 DecodedMessage::Row(row) => self.apply_logical_event(LogicalEvent::Row(row)),
203 DecodedMessage::Keepalive => {}
204 }
205 }
206
207 fn apply_logical_event(&mut self, event: LogicalEvent) {
208 match event {
209 LogicalEvent::Begin { final_lsn: _, xid } => {
210 self.transaction = Some(TransactionState { xid });
211 self.transaction_rows.clear();
212 }
213 LogicalEvent::Commit {
214 commit_lsn: _,
215 end_lsn,
216 commit_timestamp_ms,
217 } => {
218 self.commit_transaction(end_lsn.0, commit_timestamp_ms);
219 }
220 LogicalEvent::Relation(relation) => {
221 self.pending.push_back(LogicalEvent::Relation(relation));
222 }
223 LogicalEvent::Row(row) => {
224 self.enqueue_row(row);
225 }
226 }
227 }
228
229 fn enqueue_row(&mut self, row: RowEvent) {
230 if self.should_skip_row(&row) {
231 return;
232 }
233
234 if self.transaction.is_some() {
235 self.transaction_rows.insert(row);
236 } else {
237 self.pending.push_back(LogicalEvent::Row(row));
238 }
239 }
240
241 fn commit_transaction(&mut self, end_lsn: PgLsn, commit_timestamp_ms: i64) {
242 let Some(transaction) = self.transaction.take() else {
243 return;
244 };
245 if self.should_skip_lsn(end_lsn) {
246 self.transaction_rows.clear();
247 return;
248 }
249
250 let row_count = self.transaction_rows.len();
251 if row_count > 0 {
252 *self.remaining_transaction_rows.entry(end_lsn).or_default() += row_count;
253 }
254
255 for mut row in self.transaction_rows.drain(..) {
256 row.commit_lsn = Some(end_lsn);
257 row.txid = Some(transaction.xid);
258 row.commit_timestamp_ms = Some(commit_timestamp_ms);
259 self.pending.push_back(LogicalEvent::Row(row));
260 }
261 }
262
263 fn should_skip_row(&self, row: &RowEvent) -> bool {
264 let checkpoint_lsn = row.commit_lsn.unwrap_or(row.lsn);
265 if self.should_skip_resume_lsn(checkpoint_lsn) {
266 return true;
267 }
268 if row.commit_lsn.is_some() {
269 return false;
270 }
271
272 self.should_skip_checkpoint_lsn(row.lsn)
273 }
274
275 fn should_skip_lsn(&self, lsn: PgLsn) -> bool {
276 self.should_skip_resume_lsn(lsn) || self.should_skip_checkpoint_lsn(lsn)
277 }
278
279 fn should_skip_resume_lsn(&self, lsn: PgLsn) -> bool {
280 self.resume_lsn.is_some_and(|resume_lsn| lsn <= resume_lsn)
281 }
282
283 fn should_skip_checkpoint_lsn(&self, lsn: PgLsn) -> bool {
284 self.checkpoint
290 .is_some_and(|checkpoint_lsn| lsn <= checkpoint_lsn)
291 }
292
293 fn finish_pending_transaction_row(&mut self, commit_lsn: Option<PgLsn>) -> bool {
294 let Some(commit_lsn) = commit_lsn else {
295 return true;
296 };
297 #[cfg(test)]
298 {
299 self.transaction_boundary_checks += 1;
300 }
301
302 let Some(remaining) = self.remaining_transaction_rows.get_mut(&commit_lsn) else {
303 return true;
304 };
305 *remaining -= 1;
306 if *remaining == 0 {
307 self.remaining_transaction_rows.remove(&commit_lsn);
308 true
309 } else {
310 false
311 }
312 }
313}
314
315#[async_trait]
316impl Source<Bytes, Bytes> for PostgresWalSource {
317 #[tracing::instrument(
318 level = "debug",
319 skip_all,
320 fields(slot = %self.config.slot_name, table = tracing::field::Empty, op = tracing::field::Empty),
321 err,
322 )]
323 async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
324 if self.pending.is_empty() {
325 self.fill_pending_from_slot().await?;
326 }
327
328 while let Some(event) = self.pending.front().cloned() {
329 match event {
330 LogicalEvent::Begin { .. } | LogicalEvent::Commit { .. } => {
331 self.pending.pop_front();
332 self.apply_logical_event(event);
333 }
334 LogicalEvent::Relation(relation) => {
335 self.pending.pop_front();
336 self.relation_cache.apply_relation(relation);
337 }
338 LogicalEvent::Row(row) => {
339 if self.transaction.is_some() && row.commit_lsn.is_none() {
340 self.pending.pop_front();
341 self.apply_logical_event(LogicalEvent::Row(row));
342 continue;
343 }
344
345 if self.should_skip_row(&row) {
346 self.pending.pop_front();
347 self.finish_pending_transaction_row(row.commit_lsn);
348 continue;
349 }
350
351 let transaction_lsn = row.commit_lsn;
352 let diff = self.relation_cache.translate(row)?;
353 let span = tracing::Span::current();
354 span.record("table", diff.table.as_str());
355 span.record("op", operation_header(diff.op));
356 let key = self.encoder.encode_key(&diff.key)?;
357 let value = if diff.op == Operation::Delete {
358 None
359 } else {
360 Some(self.encoder.encode_value(&diff)?)
361 };
362 let mut record = ConnectRecord::new(Some(key), value)
363 .with_topic(diff.table.clone())
364 .with_header("krabka.pg.table", Some(Bytes::from(diff.table.clone())))
365 .with_header("krabka.pg.lsn", Some(Bytes::from(diff.lsn.to_string())))
366 .with_header(
367 "krabka.pg.operation",
368 Some(Bytes::from_static(operation_header(diff.op).as_bytes())),
369 );
370 if let Some(commit_timestamp_ms) = diff.commit_timestamp_ms {
371 record = record.with_timestamp(commit_timestamp_ms);
372 }
373
374 self.pending.pop_front();
375 if self.finish_pending_transaction_row(transaction_lsn) {
376 self.checkpoint = Some(diff.lsn);
377 }
378 return Ok(Some(record));
379 }
380 }
381 }
382
383 Ok(None)
384 }
385
386 fn checkpoint(&self) -> Option<SourceOffset> {
387 self.checkpoint
388 .map(|lsn| lsn.to_source_offset(&self.database_name, &self.config.slot_name))
389 }
390
391 #[tracing::instrument(level = "debug", skip_all, fields(slot = %self.config.slot_name), err)]
392 async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
393 validate_database(&offset, &self.database_name)?;
394 let lsn = PgLsn::from_source_offset(&offset, &self.config.slot_name)?;
395 self.checkpoint = Some(lsn);
396 self.resume_lsn = Some(lsn);
397 Ok(())
398 }
399
400 #[tracing::instrument(level = "debug", skip_all, fields(slot = %self.config.slot_name), err)]
401 async fn acknowledge(&mut self, offset: &SourceOffset) -> Result<(), ConnectError> {
402 validate_database(offset, &self.database_name)?;
403 let lsn = PgLsn::from_source_offset(offset, &self.config.slot_name)?;
404
405 if let Some(catalog) = &self.catalog {
406 let lsn_text = lsn.to_string();
407 catalog
408 .advance_slot(&self.config.slot_name, &lsn_text)
409 .await?;
410 }
411
412 self.resume_lsn = Some(lsn);
413 Ok(())
414 }
415}
416
417fn quote_ident(value: &str) -> String {
418 format!("\"{}\"", value.replace('"', "\"\""))
419}
420
421fn sql_string(value: &str) -> String {
422 format!("'{}'", value.replace('\'', "''"))
423}
424
425fn create_publication_sql(publication: &str, schema: &str, tables: &[String]) -> String {
426 let table_list = tables
427 .iter()
428 .map(|table| format!("{}.{}", quote_ident(schema), quote_ident(table)))
429 .collect::<Vec<_>>()
430 .join(", ");
431 let create_sql = format!(
432 "CREATE PUBLICATION {} FOR TABLE {} WITH (publish = 'insert, update, delete')",
433 quote_ident(publication),
434 table_list
435 );
436
437 format!(
438 "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = {}) THEN EXECUTE {}; END IF; END $$",
439 sql_string(publication),
440 sql_string(&create_sql)
441 )
442}
443
444pub(crate) fn peek_binary_changes_sql(slot: &str, max_messages: u32, publication: &str) -> String {
445 format!(
446 "SELECT lsn::text AS lsn, data FROM pg_logical_slot_peek_binary_changes({}, NULL, {}, 'proto_version', '1', 'publication_names', {})",
447 sql_string(slot),
448 max_messages,
449 sql_string(publication)
450 )
451}
452
453pub(crate) fn publication_tables_sql() -> &'static str {
454 "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2"
455}
456
457pub(crate) fn publication_settings_sql() -> &'static str {
458 "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1"
459}
460
461pub(crate) fn replication_slot_sql() -> &'static str {
462 "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1"
463}
464
465pub(crate) fn create_logical_slot_sql() -> &'static str {
466 "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
467}
468
469pub(crate) fn advance_slot_sql() -> &'static str {
470 "SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)"
473}
474
475#[tracing::instrument(
482 level = "info",
483 skip_all,
484 fields(slot = %config.slot_name, publication = %config.publication_name, tables = config.table_names.len()),
485 err,
486)]
487async fn initialize(
488 catalog: &dyn PgCatalog,
489 config: &PostgresSourceConfig,
490) -> Result<String, ConnectError> {
491 let database_name = catalog.current_database().await?;
492
493 if !config.table_names.is_empty() {
494 catalog
495 .ensure_publication(&create_publication_sql(
496 &config.publication_name,
497 &config.schema,
498 &config.table_names,
499 ))
500 .await?;
501 validate_publication_tables(
502 catalog,
503 &config.publication_name,
504 &config.schema,
505 &config.table_names,
506 )
507 .await?;
508 validate_publication_settings(catalog, &config.publication_name).await?;
509 }
510
511 ensure_slot(catalog, &config.slot_name, &database_name).await?;
512
513 Ok(database_name)
514}
515
516async fn validate_publication_tables(
517 catalog: &dyn PgCatalog,
518 publication: &str,
519 schema: &str,
520 tables: &[String],
521) -> Result<(), ConnectError> {
522 let published_tables = catalog.published_tables(publication, schema).await?;
523 let missing_tables = missing_publication_tables(tables, published_tables);
524
525 if missing_tables.is_empty() {
526 Ok(())
527 } else {
528 Err(ConnectError::Backend(format!(
529 "publication {publication:?} does not cover configured tables: {}",
530 missing_tables.join(", ")
531 )))
532 }
533}
534
535fn missing_publication_tables(
536 tables: &[String],
537 published_tables: impl IntoIterator<Item = String>,
538) -> Vec<String> {
539 let published_tables = published_tables.into_iter().collect::<HashSet<_>>();
540 tables
541 .iter()
542 .filter(|table| !published_tables.contains(*table))
543 .cloned()
544 .collect()
545}
546
547async fn validate_publication_settings(
548 catalog: &dyn PgCatalog,
549 publication: &str,
550) -> Result<(), ConnectError> {
551 let Some(flags) = catalog.publication_settings(publication).await? else {
552 return Ok(());
553 };
554
555 if publication_settings_are_compatible(flags) {
556 Ok(())
557 } else {
558 Err(ConnectError::Backend(format!(
559 "publication {publication:?} must publish insert, update, and delete, and must not publish truncate"
560 )))
561 }
562}
563
564fn publication_settings_are_compatible([insert, update, delete, truncate]: [bool; 4]) -> bool {
565 insert && update && delete && !truncate
566}
567
568async fn ensure_slot(
569 catalog: &dyn PgCatalog,
570 slot_name: &str,
571 database_name: &str,
572) -> Result<(), ConnectError> {
573 let Some(slot) = catalog.replication_slot(slot_name).await? else {
574 catalog.create_logical_slot(slot_name).await?;
575 return Ok(());
576 };
577
578 validate_slot_metadata(
579 slot_name,
580 slot.plugin.as_deref(),
581 &slot.slot_type,
582 slot.database.as_deref(),
583 database_name,
584 )
585}
586
587fn validate_slot_metadata(
588 slot_name: &str,
589 plugin: Option<&str>,
590 slot_type: &str,
591 database: Option<&str>,
592 database_name: &str,
593) -> Result<(), ConnectError> {
594 let mut mismatches = Vec::new();
595
596 if plugin != Some("pgoutput") {
597 mismatches.push(format!("plugin is {plugin:?}"));
598 }
599 if slot_type != "logical" {
600 mismatches.push(format!("slot_type is {slot_type:?}"));
601 }
602 if database != Some(database_name) {
603 mismatches.push(format!("database is {database:?}"));
604 }
605
606 if mismatches.is_empty() {
607 Ok(())
608 } else {
609 Err(ConnectError::Backend(format!(
610 "replication slot {slot_name:?} is not compatible: {}",
611 mismatches.join(", ")
612 )))
613 }
614}
615
616fn validate_database(offset: &SourceOffset, expected_database: &str) -> Result<(), ConnectError> {
617 match offset.partition.get("database") {
618 Some(OffsetValue::String(database)) if database == expected_database => Ok(()),
619 Some(OffsetValue::String(database)) => Err(ConnectError::Offset(format!(
620 "source offset database {database:?} does not match expected database {expected_database:?}"
621 ))),
622 _ => Err(ConnectError::Offset(
623 "source offset missing string database".to_owned(),
624 )),
625 }
626}
627
628fn operation_header(operation: Operation) -> &'static str {
629 match operation {
630 Operation::Insert => "insert",
631 Operation::Update => "update",
632 Operation::Delete => "delete",
633 }
634}
635
636#[cfg(test)]
637mod sql_tests {
638 use assert2::check;
639
640 use super::{
641 advance_slot_sql, create_logical_slot_sql, create_publication_sql,
642 missing_publication_tables, peek_binary_changes_sql, publication_settings_are_compatible,
643 publication_settings_sql, publication_tables_sql, replication_slot_sql,
644 validate_slot_metadata,
645 };
646
647 #[test]
648 fn create_logical_slot_sql_uses_pgoutput_plugin() {
649 check!(
650 create_logical_slot_sql()
651 == "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
652 );
653 }
654
655 #[test]
656 fn publication_sql_quotes_identifiers() {
657 let sql = create_publication_sql(
658 "pub\"name",
659 "sch\"ema",
660 &["orders".to_owned(), "line\"items".to_owned()],
661 );
662
663 check!(sql.contains("CREATE PUBLICATION \"pub\"\"name\""));
664 check!(
665 sql.contains("FOR TABLE \"sch\"\"ema\".\"orders\", \"sch\"\"ema\".\"line\"\"items\"")
666 );
667 check!(sql.contains("WHERE pubname = 'pub\"name'"));
668 }
669
670 #[test]
671 fn peek_binary_changes_sql_escapes_string_literals_without_advancing_slot() {
672 let sql = peek_binary_changes_sql("slot'name", 25, "pub'name");
673
674 check!(
675 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')"
676 );
677 }
678
679 #[test]
680 fn publication_sql_excludes_truncate_events() {
681 let sql = create_publication_sql("pub", "public", &["orders".to_owned()]);
682
683 check!(sql.contains("WITH (publish = ''insert, update, delete'')"));
684 }
685
686 #[test]
687 fn validation_sql_cases() {
688 for (_name, actual, expected) in [
689 (
690 "publication_tables",
691 publication_tables_sql(),
692 "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2",
693 ),
694 (
695 "publication_settings",
696 publication_settings_sql(),
697 "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1",
698 ),
699 (
700 "replication_slot",
701 replication_slot_sql(),
702 "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1",
703 ),
704 ] {
705 assert2::assert!(actual == expected);
706 }
707 }
708
709 #[test]
710 fn advance_slot_binds_lsn_as_text_before_server_cast() {
711 check!(advance_slot_sql() == "SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)");
712 }
713
714 #[test]
715 fn publication_table_validation_reports_only_missing_configured_tables() {
716 let missing = missing_publication_tables(
717 &["orders".to_owned(), "accounts".to_owned()],
718 ["orders".to_owned(), "ignored".to_owned()],
719 );
720
721 check!(missing == vec!["accounts".to_owned()]);
722 }
723
724 #[test]
725 fn publication_settings_require_insert_update_delete_without_truncate() {
726 for (name, flags, expected) in [
727 ("required-flags", [true, true, true, false], true),
728 ("missing-insert", [false, true, true, false], false),
729 ("missing-update", [true, false, true, false], false),
730 ("missing-delete", [true, true, false, false], false),
731 ("includes-truncate", [true, true, true, true], false),
732 ] {
733 check!(
734 publication_settings_are_compatible(flags) == expected,
735 "case {name}"
736 );
737 }
738 }
739
740 #[test]
741 fn slot_metadata_accepts_pgoutput_logical_slot_for_current_database() {
742 validate_slot_metadata("slot_a", Some("pgoutput"), "logical", Some("app"), "app")
743 .expect("matching slot should validate");
744 }
745
746 #[test]
747 fn slot_metadata_reports_all_incompatible_fields() {
748 let error =
749 validate_slot_metadata("slot_a", Some("test_decoding"), "physical", None, "app")
750 .expect_err("incompatible slot should fail");
751
752 match error {
753 krabka_connect::ConnectError::Backend(message) => {
754 check!(
755 (
756 message.contains("replication slot \"slot_a\" is not compatible"),
757 message.contains("plugin is Some(\"test_decoding\")"),
758 message.contains("slot_type is \"physical\""),
759 message.contains("database is None"),
760 ) == (true, true, true, true)
761 );
762 }
763 error => panic!("expected backend error, got {error:?}"),
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use std::collections::VecDeque;
771
772 use assert2::check;
773 use bytes::Bytes;
774 use krabka_connect::{SecretString, Source as _};
775 use krabka_schema_serde::wire::MAGIC;
776
777 use super::{LogicalEvent, PostgresWalSource, TransactionState, validate_database};
778 use crate::{
779 PgLsn, PostgresSourceConfig,
780 ids::{CommitLsn, EndLsn, RelationId, TransactionId},
781 model::{ColumnSchema, ColumnValue, ScalarValue},
782 pgoutput::{RelationEvent, RowEvent, RowEventKind, RowTupleKind},
783 };
784
785 fn header_value(
786 record: &krabka_connect::ConnectRecord<bytes::Bytes, bytes::Bytes>,
787 key: &str,
788 ) -> bytes::Bytes {
789 record
790 .headers
791 .iter()
792 .find(|header| header.key == key)
793 .and_then(|header| header.value.clone())
794 .unwrap_or_else(|| panic!("missing header {key}"))
795 }
796
797 fn config(slot_name: &str) -> PostgresSourceConfig {
798 PostgresSourceConfig {
799 schema_registry_url: "http://localhost:8081".to_owned(),
800 database_url: SecretString::new("postgres://localhost/app"),
801 slot_name: slot_name.to_owned(),
802 publication_name: "krabka_connect".to_owned(),
803 schema: "public".to_owned(),
804 table_names: vec!["orders".to_owned()],
805 max_messages_per_poll: 1000,
806 }
807 }
808
809 fn orders_relation() -> RelationEvent {
810 RelationEvent {
811 relation_id: RelationId(7),
812 schema: "public".to_owned(),
813 table: "orders".to_owned(),
814 columns: vec![
815 ColumnSchema {
816 name: "id".to_owned(),
817 type_name: "int8".to_owned(),
818 key: true,
819 },
820 ColumnSchema {
821 name: "status".to_owned(),
822 type_name: "text".to_owned(),
823 key: false,
824 },
825 ],
826 }
827 }
828
829 fn id(value: i64) -> ColumnValue {
830 ColumnValue {
831 name: "id".to_owned(),
832 value: ScalarValue::Int(value),
833 }
834 }
835
836 fn status(value: &str) -> ColumnValue {
837 ColumnValue {
838 name: "status".to_owned(),
839 value: ScalarValue::Text(value.to_owned()),
840 }
841 }
842
843 fn insert_event(lsn: PgLsn) -> RowEvent {
844 RowEvent {
845 relation_id: RelationId(7),
846 lsn,
847 commit_lsn: None,
848 txid: Some(TransactionId(99)),
849 commit_timestamp_ms: Some(1_700_000_000_000),
850 kind: RowEventKind::Insert,
851 values: vec![id(42), status("paid")],
852 }
853 }
854
855 fn delete_event(lsn: PgLsn) -> RowEvent {
856 RowEvent {
857 relation_id: RelationId(7),
858 lsn,
859 commit_lsn: None,
860 txid: None,
861 commit_timestamp_ms: None,
862 kind: RowEventKind::Delete {
863 tuple_kind: RowTupleKind::Full,
864 },
865 values: vec![id(42), status("cancelled")],
866 }
867 }
868
869 #[test]
870 fn transaction_rows_keep_order_and_distinct_payloads_while_deduplicating_replays() {
871 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
872 source.transaction = Some(TransactionState {
873 xid: TransactionId(99),
874 });
875 let first = insert_event(PgLsn(42));
876 let mut changed = first.clone();
877 changed.values = vec![id(42), status("shipped")];
878 let mut later = first.clone();
879 later.lsn = PgLsn(43);
880 let deleted = delete_event(PgLsn(44));
881 let expected = vec![
882 first.clone(),
883 changed.clone(),
884 later.clone(),
885 deleted.clone(),
886 ];
887 for row in [first.clone(), changed, first, later, deleted] {
888 source.enqueue_row(row);
889 }
890 assert2::assert!(source.transaction_rows.iter().cloned().collect::<Vec<_>>() == expected);
891 source.commit_transaction(PgLsn(100), 123);
892 let expected = expected
893 .into_iter()
894 .map(|mut row| {
895 row.commit_lsn = Some(PgLsn(100));
896 row.txid = Some(TransactionId(99));
897 row.commit_timestamp_ms = Some(123);
898 LogicalEvent::Row(row)
899 })
900 .collect::<VecDeque<_>>();
901 assert2::assert!(source.pending == expected);
902 assert2::assert!(source.transaction_rows.is_empty());
903
904 source.transaction = Some(TransactionState {
905 xid: TransactionId(100),
906 });
907 source.enqueue_row(insert_event(PgLsn(42)));
908 assert2::assert!(source.transaction_rows.len() == 1);
909 }
910
911 #[tokio::test]
912 async fn insert_poll_emits_framed_record_and_checkpoint() {
913 let mut source = PostgresWalSource::scripted(
914 config("slot_a"),
915 "app",
916 [
917 LogicalEvent::Relation(orders_relation()),
918 LogicalEvent::Row(insert_event(PgLsn(0x2a))),
919 ],
920 )
921 .expect("source builds");
922
923 let record = source
924 .poll()
925 .await
926 .expect("poll succeeds")
927 .expect("row emits");
928
929 check!(
930 (
931 record.key.as_ref().map(|key| key[0]),
932 record.value.as_ref().map(|value| value[0]),
933 record.topic.as_deref(),
934 record.partition,
935 record.timestamp,
936 header_value(&record, "krabka.pg.table"),
937 header_value(&record, "krabka.pg.lsn"),
938 header_value(&record, "krabka.pg.operation"),
939 source.checkpoint(),
940 ) == (
941 Some(MAGIC),
942 Some(MAGIC),
943 Some("public.orders"),
944 None,
945 Some(1_700_000_000_000),
946 Bytes::from_static(b"public.orders"),
947 Bytes::from_static(b"0/2A"),
948 Bytes::from_static(b"insert"),
949 Some(PgLsn(0x2a).to_source_offset("app", "slot_a")),
950 )
951 );
952 }
953
954 #[tokio::test]
955 async fn delete_poll_emits_tombstone() {
956 let mut source = PostgresWalSource::scripted(
957 config("slot_a"),
958 "app",
959 [
960 LogicalEvent::Relation(orders_relation()),
961 LogicalEvent::Row(delete_event(PgLsn(0x2b))),
962 ],
963 )
964 .expect("source builds");
965
966 let record = source
967 .poll()
968 .await
969 .expect("poll succeeds")
970 .expect("row emits");
971
972 check!(
973 (
974 record.key.is_some(),
975 record.value.is_some(),
976 record.topic.as_deref(),
977 record.partition,
978 record.timestamp,
979 header_value(&record, "krabka.pg.table"),
980 header_value(&record, "krabka.pg.lsn"),
981 header_value(&record, "krabka.pg.operation"),
982 ) == (
983 true,
984 false,
985 Some("public.orders"),
986 None,
987 None,
988 Bytes::from_static(b"public.orders"),
989 Bytes::from_static(b"0/2B"),
990 Bytes::from_static(b"delete"),
991 )
992 );
993 }
994
995 #[tokio::test]
996 async fn seek_restores_lsn_checkpoint() {
997 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
998 let offset = PgLsn(0x2a).to_source_offset("app", "slot_a");
999
1000 source.seek(offset).await.expect("seek succeeds");
1001
1002 check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
1003 }
1004
1005 #[test]
1006 fn skip_lsn_checks_resume_and_checkpoint_offsets_independently() {
1007 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1008 source.resume_lsn = Some(PgLsn(0x20));
1009
1010 check!(source.should_skip_lsn(PgLsn(0x20)));
1011 check!(!source.should_skip_lsn(PgLsn(0x21)));
1012
1013 source.resume_lsn = None;
1014 source.checkpoint = Some(PgLsn(0x30));
1015
1016 check!(source.should_skip_lsn(PgLsn(0x30)));
1017 check!(!source.should_skip_lsn(PgLsn(0x31)));
1018
1019 source.resume_lsn = Some(PgLsn(0x20));
1020 source.checkpoint = Some(PgLsn(0x30));
1021
1022 check!(source.should_skip_lsn(PgLsn(0x20)));
1023 check!(source.should_skip_lsn(PgLsn(0x30)));
1024 check!(!source.should_skip_lsn(PgLsn(0x31)));
1025 }
1026
1027 #[tokio::test]
1028 async fn seek_past_first_row_poll_emits_only_later_row_without_regressing_checkpoint() {
1029 let mut source = PostgresWalSource::scripted(
1030 config("slot_a"),
1031 "app",
1032 [
1033 LogicalEvent::Relation(orders_relation()),
1034 LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1035 LogicalEvent::Row(insert_event(PgLsn(0x2b))),
1036 ],
1037 )
1038 .expect("source builds");
1039
1040 source
1041 .seek(PgLsn(0x2a).to_source_offset("app", "slot_a"))
1042 .await
1043 .expect("seek succeeds");
1044
1045 let record = source
1046 .poll()
1047 .await
1048 .expect("poll succeeds")
1049 .expect("later row emits");
1050
1051 check!(header_value(&record, "krabka.pg.lsn").as_ref() == b"0/2B");
1052 check!(source.checkpoint() == Some(PgLsn(0x2b).to_source_offset("app", "slot_a")));
1053 check!(source.poll().await.expect("poll succeeds").is_none());
1054 }
1055
1056 #[tokio::test]
1057 async fn checkpointed_duplicate_row_is_skipped_without_regressing_checkpoint() {
1058 let mut source = PostgresWalSource::scripted(
1059 config("slot_a"),
1060 "app",
1061 [
1062 LogicalEvent::Relation(orders_relation()),
1063 LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1064 LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1065 ],
1066 )
1067 .expect("source builds");
1068
1069 let record = source
1070 .poll()
1071 .await
1072 .expect("poll succeeds")
1073 .expect("first row emits");
1074
1075 check!(header_value(&record, "krabka.pg.lsn").as_ref() == b"0/2A");
1076 check!(source.poll().await.expect("poll succeeds").is_none());
1077 check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
1078 }
1079
1080 #[tokio::test]
1081 async fn translation_failure_does_not_drop_pending_row() {
1082 let mut source = PostgresWalSource::scripted(
1083 config("slot_a"),
1084 "app",
1085 [LogicalEvent::Row(insert_event(PgLsn(0x2a)))],
1086 )
1087 .expect("source builds");
1088
1089 check!(source.poll().await.is_err());
1090
1091 check!(source.pending_len() == 1);
1092 check!(source.checkpoint().is_none());
1093 }
1094
1095 #[tokio::test]
1096 async fn seek_rejects_offset_for_different_database() {
1097 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1098 let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
1099
1100 let error = source.seek(offset).await.expect_err("database mismatch");
1101
1102 check!(matches!(error, krabka_connect::ConnectError::Offset(_)));
1103 check!(source.checkpoint().is_none());
1104 }
1105
1106 #[test]
1107 fn validate_database_rejects_missing_or_non_string_database_partition() {
1108 let missing = krabka_connect::SourceOffset::default();
1109 let mut non_string = krabka_connect::SourceOffset::default();
1110 non_string
1111 .partition
1112 .0
1113 .insert("database".to_owned(), krabka_connect::OffsetValue::Long(7));
1114
1115 for (_name, offset) in [("missing", missing), ("non_string", non_string)] {
1116 assert2::assert!(matches!(
1117 validate_database(&offset, "app"),
1118 Err(krabka_connect::ConnectError::Offset(_))
1119 ));
1120 }
1121 }
1122
1123 #[test]
1124 fn validate_database_reports_database_mismatch() {
1125 let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
1126
1127 let error = validate_database(&offset, "app").expect_err("database mismatch should fail");
1128
1129 match error {
1130 krabka_connect::ConnectError::Offset(message) => {
1131 check!(message.contains("does not match expected database"));
1132 check!(message.contains("other_app"));
1133 check!(message.contains("app"));
1134 }
1135 error => panic!("expected offset error, got {error:?}"),
1136 }
1137 }
1138
1139 #[tokio::test]
1140 async fn scripted_acknowledge_accepts_matching_offset_and_rejects_database_mismatch() {
1141 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1142
1143 source
1144 .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
1145 .await
1146 .expect("matching offset acknowledged");
1147
1148 let error = source
1149 .acknowledge(&PgLsn(0x2b).to_source_offset("other_app", "slot_a"))
1150 .await
1151 .expect_err("database mismatch rejected");
1152
1153 check!(matches!(error, krabka_connect::ConnectError::Offset(_)));
1154 }
1155
1156 #[tokio::test]
1157 async fn relation_only_poll_returns_none_without_checkpoint() {
1158 let mut source = PostgresWalSource::scripted(
1159 config("slot_a"),
1160 "app",
1161 [LogicalEvent::Relation(orders_relation())],
1162 )
1163 .expect("source builds");
1164
1165 check!(source.poll().await.expect("poll succeeds").is_none());
1166 check!(source.checkpoint().is_none());
1167 }
1168
1169 #[tokio::test]
1170 async fn transaction_commit_metadata_is_applied_to_all_rows() {
1171 let mut second = insert_event(PgLsn(0x2b));
1172 second.values = vec![id(43), status("pending")];
1173 let mut source = PostgresWalSource::scripted(
1174 config("slot_a"),
1175 "app",
1176 [
1177 LogicalEvent::Relation(orders_relation()),
1178 LogicalEvent::Begin {
1179 final_lsn: PgLsn(0x40),
1180 xid: TransactionId(123),
1181 },
1182 LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1183 LogicalEvent::Row(second),
1184 LogicalEvent::Commit {
1185 commit_lsn: CommitLsn(PgLsn(0x41)),
1186 end_lsn: EndLsn(PgLsn(0x42)),
1187 commit_timestamp_ms: 1_700_000_000_123,
1188 },
1189 ],
1190 )
1191 .expect("source builds");
1192
1193 let first = source
1194 .poll()
1195 .await
1196 .expect("first poll succeeds")
1197 .expect("first row emits");
1198 check!(source.checkpoint().is_none());
1199 let second = source
1200 .poll()
1201 .await
1202 .expect("second poll succeeds")
1203 .expect("second row emits");
1204
1205 check!(
1206 (
1207 header_value(&first, "krabka.pg.lsn"),
1208 header_value(&second, "krabka.pg.lsn"),
1209 first.timestamp,
1210 second.timestamp,
1211 source.checkpoint(),
1212 ) == (
1213 Bytes::from_static(b"0/42"),
1214 Bytes::from_static(b"0/42"),
1215 Some(1_700_000_000_123),
1216 Some(1_700_000_000_123),
1217 Some(PgLsn(0x42).to_source_offset("app", "slot_a")),
1218 )
1219 );
1220 check!(source.poll().await.expect("poll succeeds").is_none());
1221 }
1222
1223 #[tokio::test]
1224 async fn mixed_transactions_preserve_order_metadata_and_checkpoint_boundaries() {
1225 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1226 source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1227 source.apply_logical_event(LogicalEvent::Begin {
1228 final_lsn: PgLsn(0x40),
1229 xid: TransactionId(123),
1230 });
1231 source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2a))));
1232 source.apply_logical_event(LogicalEvent::Row(delete_event(PgLsn(0x2b))));
1233 source.apply_logical_event(LogicalEvent::Commit {
1234 commit_lsn: CommitLsn(PgLsn(0x41)),
1235 end_lsn: EndLsn(PgLsn(0x42)),
1236 commit_timestamp_ms: 1_700_000_000_123,
1237 });
1238 source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1239 source.apply_logical_event(LogicalEvent::Begin {
1240 final_lsn: PgLsn(0x50),
1241 xid: TransactionId(124),
1242 });
1243 source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2c))));
1244 source.apply_logical_event(LogicalEvent::Commit {
1245 commit_lsn: CommitLsn(PgLsn(0x51)),
1246 end_lsn: EndLsn(PgLsn(0x52)),
1247 commit_timestamp_ms: 1_700_000_000_456,
1248 });
1249
1250 let first = source.poll().await.unwrap().unwrap();
1251 check!(source.checkpoint().is_none());
1252 let second = source.poll().await.unwrap().unwrap();
1253 check!(source.checkpoint() == Some(PgLsn(0x42).to_source_offset("app", "slot_a")));
1254 let third = source.poll().await.unwrap().unwrap();
1255
1256 check!(
1257 [
1258 (
1259 header_value(&first, "krabka.pg.operation"),
1260 header_value(&first, "krabka.pg.lsn"),
1261 first.timestamp,
1262 ),
1263 (
1264 header_value(&second, "krabka.pg.operation"),
1265 header_value(&second, "krabka.pg.lsn"),
1266 second.timestamp,
1267 ),
1268 (
1269 header_value(&third, "krabka.pg.operation"),
1270 header_value(&third, "krabka.pg.lsn"),
1271 third.timestamp,
1272 ),
1273 ] == [
1274 (
1275 Bytes::from_static(b"insert"),
1276 Bytes::from_static(b"0/42"),
1277 Some(1_700_000_000_123),
1278 ),
1279 (
1280 Bytes::from_static(b"delete"),
1281 Bytes::from_static(b"0/42"),
1282 Some(1_700_000_000_123),
1283 ),
1284 (
1285 Bytes::from_static(b"insert"),
1286 Bytes::from_static(b"0/52"),
1287 Some(1_700_000_000_456),
1288 ),
1289 ]
1290 );
1291 check!(source.checkpoint() == Some(PgLsn(0x52).to_source_offset("app", "slot_a")));
1292 }
1293
1294 #[tokio::test]
1295 async fn transaction_boundary_bookkeeping_scales_with_committed_rows() {
1296 for transaction_count in [1_000_u32, 2_000, 4_000] {
1297 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1298 source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1299 for index in 0..transaction_count {
1300 source.apply_logical_event(LogicalEvent::Begin {
1301 final_lsn: PgLsn(u64::from(index + 1)),
1302 xid: TransactionId(i64::from(index + 1)),
1303 });
1304 source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(u64::from(
1305 index + 1,
1306 )))));
1307 source.apply_logical_event(LogicalEvent::Commit {
1308 commit_lsn: CommitLsn(PgLsn(u64::from(index + 1))),
1309 end_lsn: EndLsn(PgLsn(u64::from(10_000 + index))),
1310 commit_timestamp_ms: i64::from(index),
1311 });
1312 }
1313
1314 let mut emitted = 0;
1315 while source.poll().await.unwrap().is_some() {
1316 emitted += 1;
1317 }
1318
1319 check!(emitted == transaction_count);
1320 check!(
1321 source.transaction_boundary_checks
1322 == usize::try_from(transaction_count).expect("test count fits usize")
1323 );
1324 check!(source.remaining_transaction_rows.is_empty());
1325 }
1326 }
1327
1328 #[tokio::test]
1329 async fn failed_transaction_final_row_keeps_boundary_for_retry() {
1330 let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1331 source.apply_logical_event(LogicalEvent::Begin {
1332 final_lsn: PgLsn(0x40),
1333 xid: TransactionId(123),
1334 });
1335 source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2a))));
1336 source.apply_logical_event(LogicalEvent::Commit {
1337 commit_lsn: CommitLsn(PgLsn(0x41)),
1338 end_lsn: EndLsn(PgLsn(0x42)),
1339 commit_timestamp_ms: 1_700_000_000_123,
1340 });
1341
1342 check!(source.poll().await.is_err());
1343 check!(source.pending_len() == 1);
1344 check!(source.checkpoint().is_none());
1345 check!(source.remaining_transaction_rows.get(&PgLsn(0x42)) == Some(&1));
1346
1347 source.relation_cache.apply_relation(orders_relation());
1348 check!(source.poll().await.unwrap().is_some());
1349 check!(source.checkpoint() == Some(PgLsn(0x42).to_source_offset("app", "slot_a")));
1350 check!(source.remaining_transaction_rows.is_empty());
1351 }
1352
1353 #[test]
1354 fn decoded_transaction_messages_stage_rows_with_commit_metadata() {
1355 let mut source =
1356 PostgresWalSource::scripted(config("slot_a"), "app", []).expect("source builds");
1357 let mut row = insert_event(PgLsn(0x2a));
1358 row.txid = None;
1359 row.commit_timestamp_ms = None;
1360
1361 source.apply_decoded_message(crate::pgoutput::DecodedMessage::Begin {
1362 final_lsn: PgLsn(0x40),
1363 xid: TransactionId(123),
1364 });
1365 source.apply_decoded_message(crate::pgoutput::DecodedMessage::Row(row));
1366 source.apply_decoded_message(crate::pgoutput::DecodedMessage::Commit {
1367 commit_lsn: CommitLsn(PgLsn(0x41)),
1368 end_lsn: EndLsn(PgLsn(0x42)),
1369 commit_timestamp_ms: 1_700_000_000_123,
1370 });
1371
1372 let Some(LogicalEvent::Row(row)) = source.pending.pop_front() else {
1373 panic!("committed row should be pending");
1374 };
1375 let mut expected = insert_event(PgLsn(0x2a));
1376 expected.commit_lsn = Some(PgLsn(0x42));
1377 expected.txid = Some(TransactionId(123));
1378 expected.commit_timestamp_ms = Some(1_700_000_000_123);
1379 check!(row == expected);
1380 }
1381}
1382
1383#[cfg(test)]
1390mod catalog_tests {
1391 use assert2::check;
1392 use krabka_connect::{ConnectError, SecretString, Source as _};
1393
1394 use super::{
1395 PostgresWalSource, ensure_slot, initialize, validate_publication_settings,
1396 validate_publication_tables,
1397 };
1398 use crate::{
1399 PgLsn, PostgresSourceConfig,
1400 catalog::{MockPgCatalog, SlotChange, SlotMetadata},
1401 };
1402
1403 fn config_with_tables(tables: Vec<String>) -> PostgresSourceConfig {
1404 PostgresSourceConfig {
1405 schema_registry_url: "http://localhost:8081".to_owned(),
1406 database_url: SecretString::new("postgres://localhost/app"),
1407 slot_name: "slot_a".to_owned(),
1408 publication_name: "krabka_connect".to_owned(),
1409 schema: "public".to_owned(),
1410 table_names: tables,
1411 max_messages_per_poll: 1000,
1412 }
1413 }
1414
1415 fn valid_slot() -> SlotMetadata {
1416 SlotMetadata {
1417 plugin: Some("pgoutput".to_owned()),
1418 slot_type: "logical".to_owned(),
1419 database: Some("app".to_owned()),
1420 }
1421 }
1422
1423 fn begin_frame() -> Vec<u8> {
1427 let mut data = vec![b'B'];
1428 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
1432 }
1433
1434 #[tokio::test]
1435 async fn validate_publication_tables_accepts_full_coverage_and_reports_gaps() {
1436 let mut catalog = MockPgCatalog::new();
1437 catalog
1438 .expect_published_tables()
1439 .returning(|_, _| Ok(vec!["orders".to_owned()]));
1440
1441 validate_publication_tables(&catalog, "krabka_connect", "public", &["orders".to_owned()])
1442 .await
1443 .expect("full coverage validates");
1444
1445 let mut missing = MockPgCatalog::new();
1446 missing
1447 .expect_published_tables()
1448 .returning(|_, _| Ok(Vec::new()));
1449
1450 let error = validate_publication_tables(
1451 &missing,
1452 "krabka_connect",
1453 "public",
1454 &["orders".to_owned()],
1455 )
1456 .await
1457 .expect_err("uncovered table fails");
1458 match error {
1459 ConnectError::Backend(message) => {
1460 check!(message.contains("does not cover configured tables"));
1461 check!(message.contains("orders"));
1462 }
1463 error => panic!("expected backend error, got {error:?}"),
1464 }
1465 }
1466
1467 #[tokio::test]
1468 async fn validate_publication_settings_requires_insert_update_delete_without_truncate() {
1469 let mut compatible = MockPgCatalog::new();
1470 compatible
1471 .expect_publication_settings()
1472 .returning(|_| Ok(Some([true, true, true, false])));
1473 validate_publication_settings(&compatible, "krabka_connect")
1474 .await
1475 .expect("compatible flags validate");
1476
1477 let mut truncating = MockPgCatalog::new();
1478 truncating
1479 .expect_publication_settings()
1480 .returning(|_| Ok(Some([true, true, true, true])));
1481 let error = validate_publication_settings(&truncating, "krabka_connect")
1482 .await
1483 .expect_err("publishing truncate fails");
1484 check!(matches!(error, ConnectError::Backend(_)));
1485
1486 let mut absent = MockPgCatalog::new();
1488 absent.expect_publication_settings().returning(|_| Ok(None));
1489 validate_publication_settings(&absent, "krabka_connect")
1490 .await
1491 .expect("missing publication row is tolerated");
1492 }
1493
1494 #[tokio::test]
1495 async fn ensure_slot_creates_when_absent_and_validates_when_present() {
1496 let mut absent = MockPgCatalog::new();
1497 absent.expect_replication_slot().returning(|_| Ok(None));
1498 absent
1499 .expect_create_logical_slot()
1500 .times(1)
1501 .returning(|_| Ok(()));
1502 ensure_slot(&absent, "slot_a", "app")
1503 .await
1504 .expect("absent slot is created");
1505
1506 let mut present = MockPgCatalog::new();
1508 present
1509 .expect_replication_slot()
1510 .returning(|_| Ok(Some(valid_slot())));
1511 ensure_slot(&present, "slot_a", "app")
1512 .await
1513 .expect("compatible slot validates");
1514
1515 let mut mismatched = MockPgCatalog::new();
1516 mismatched.expect_replication_slot().returning(|_| {
1517 Ok(Some(SlotMetadata {
1518 plugin: Some("test_decoding".to_owned()),
1519 slot_type: "physical".to_owned(),
1520 database: Some("other".to_owned()),
1521 }))
1522 });
1523 let error = ensure_slot(&mismatched, "slot_a", "app")
1524 .await
1525 .expect_err("incompatible slot fails");
1526 check!(matches!(error, ConnectError::Backend(_)));
1527 }
1528
1529 #[tokio::test]
1530 async fn initialize_runs_publication_setup_only_when_tables_configured() {
1531 let mut with_tables = MockPgCatalog::new();
1532 with_tables
1533 .expect_current_database()
1534 .returning(|| Ok("app".to_owned()));
1535 with_tables
1536 .expect_ensure_publication()
1537 .times(1)
1538 .returning(|_| Ok(()));
1539 with_tables
1540 .expect_published_tables()
1541 .returning(|_, _| Ok(vec!["orders".to_owned()]));
1542 with_tables
1543 .expect_publication_settings()
1544 .returning(|_| Ok(Some([true, true, true, false])));
1545 with_tables
1546 .expect_replication_slot()
1547 .returning(|_| Ok(Some(valid_slot())));
1548
1549 let database = initialize(&with_tables, &config_with_tables(vec!["orders".to_owned()]))
1550 .await
1551 .expect("initialize succeeds");
1552 check!(database == "app");
1553
1554 let mut no_tables = MockPgCatalog::new();
1557 no_tables
1558 .expect_current_database()
1559 .returning(|| Ok("app".to_owned()));
1560 no_tables
1561 .expect_replication_slot()
1562 .returning(|_| Ok(Some(valid_slot())));
1563 initialize(&no_tables, &config_with_tables(Vec::new()))
1564 .await
1565 .expect("initialize without tables succeeds");
1566 }
1567
1568 #[tokio::test]
1569 async fn fill_pending_applies_peeked_changes_and_propagates_decode_input() {
1570 let mut catalog = MockPgCatalog::new();
1571 catalog.expect_peek_changes().returning(|_, _, _| {
1572 Ok(vec![SlotChange {
1573 lsn: "0/2A".to_owned(),
1574 data: begin_frame(),
1575 }])
1576 });
1577
1578 let mut source = PostgresWalSource::with_catalog(
1579 config_with_tables(Vec::new()),
1580 "app",
1581 Box::new(catalog),
1582 )
1583 .expect("source builds");
1584 source
1585 .fill_pending_from_slot()
1586 .await
1587 .expect("fill succeeds");
1588 check!(source.transaction.is_some());
1591 }
1592
1593 #[tokio::test]
1594 async fn fill_pending_surfaces_unparsable_lsn() {
1595 let mut catalog = MockPgCatalog::new();
1596 catalog.expect_peek_changes().returning(|_, _, _| {
1597 Ok(vec![SlotChange {
1598 lsn: "not-a-lsn".to_owned(),
1599 data: begin_frame(),
1600 }])
1601 });
1602
1603 let mut source = PostgresWalSource::with_catalog(
1604 config_with_tables(Vec::new()),
1605 "app",
1606 Box::new(catalog),
1607 )
1608 .expect("source builds");
1609 check!(source.fill_pending_from_slot().await.is_err());
1610 }
1611
1612 #[tokio::test]
1613 async fn fill_pending_without_catalog_is_a_noop() {
1614 let mut source =
1615 PostgresWalSource::scripted(config_with_tables(Vec::new()), "app", []).unwrap();
1616 source
1617 .fill_pending_from_slot()
1618 .await
1619 .expect("noop succeeds");
1620 check!(source.pending_len() == 0);
1621 }
1622
1623 #[tokio::test]
1624 async fn acknowledge_advances_slot_through_catalog() {
1625 let mut catalog = MockPgCatalog::new();
1626 catalog
1627 .expect_advance_slot()
1628 .times(1)
1629 .returning(|_, _| Ok(()));
1630
1631 let mut source = PostgresWalSource::with_catalog(
1632 config_with_tables(Vec::new()),
1633 "app",
1634 Box::new(catalog),
1635 )
1636 .expect("source builds");
1637 source
1638 .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
1639 .await
1640 .expect("acknowledge advances the slot");
1641 }
1642}