This is an automated email from the ASF dual-hosted git repository.
mmodzelewski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 6513b2f55 fix(connectors): fix postgres source CDC producing no
messages (#3640)
6513b2f55 is described below
commit 6513b2f5542b87461449c6d67e198eeffeb0536b
Author: Matthew Patton <[email protected]>
AuthorDate: Thu Jul 23 08:52:57 2026 -0400
fix(connectors): fix postgres source CDC producing no messages (#3640)
---
.gitignore | 2 +
core/connectors/sources/postgres_source/README.md | 31 +-
.../sources/postgres_source/src/cdc_fixtures.rs | 89 ++
core/connectors/sources/postgres_source/src/lib.rs | 1100 ++++++++++++++++----
core/integration/tests/connectors/fixtures/mod.rs | 5 +-
.../tests/connectors/fixtures/postgres/cdc.rs | 206 ++++
.../connectors/fixtures/postgres/container.rs | 21 +-
.../tests/connectors/fixtures/postgres/mod.rs | 2 +
.../postgres/cdc_restart_connectors/config.toml | 42 +
core/integration/tests/connectors/postgres/mod.rs | 1 +
.../connectors/postgres/postgres_source_cdc.rs | 584 +++++++++++
.../connectors/postgres/source_cdc_restart.toml | 26 +
12 files changed, 1886 insertions(+), 223 deletions(-)
diff --git a/.gitignore b/.gitignore
index f0d8b6250..45260878f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,5 +40,7 @@ foreign/node/.npm
*.out
go.work
core/bench/dashboard/frontend/dist
+core/integration/tests/connectors/postgres/cdc_restart_connectors/*
+!core/integration/tests/connectors/postgres/cdc_restart_connectors/config.toml
LICENSE-binary
**/LICENSE-binary
diff --git a/core/connectors/sources/postgres_source/README.md
b/core/connectors/sources/postgres_source/README.md
index ba03b51bf..d0619bdea 100644
--- a/core/connectors/sources/postgres_source/README.md
+++ b/core/connectors/sources/postgres_source/README.md
@@ -41,9 +41,7 @@ primary_key_column = "id"
# Custom query (optional)
custom_query = "SELECT * FROM $table WHERE id > $offset ORDER BY id LIMIT
$limit"
-# CDC options (optional)
-enable_wal_cdc = false
-publication_name = "iggy_publication"
+# CDC options (only used when mode = "cdc")
replication_slot = "iggy_slot"
capture_operations = ["INSERT", "UPDATE", "DELETE"]
cdc_backend = "builtin"
@@ -69,9 +67,7 @@ cdc_backend = "builtin"
| `processed_column` | string | none | Boolean column to mark as processed |
| `primary_key_column` | string | tracking_column | PK for delete/mark
operations |
| `custom_query` | string | none | Custom SQL with parameter substitution |
-| `enable_wal_cdc` | bool | `false` | Enable WAL-based CDC |
-| `publication_name` | string | `iggy_publication` | Logical replication
publication |
-| `replication_slot` | string | `iggy_slot` | Replication slot name |
+| `replication_slot` | string | `iggy_slot` | Replication slot name (only used
when `mode = "cdc"`) |
| `capture_operations` | array | `["INSERT","UPDATE","DELETE"]` | CDC
operations to capture |
| `cdc_backend` | string | `builtin` | `builtin` or `pg_replicate` |
| `verbose_logging` | bool | `false` | Log at info level instead of debug |
@@ -267,13 +263,33 @@ CDC requires PostgreSQL logical replication setup:
```toml
[plugin_config]
mode = "cdc"
-enable_wal_cdc = true
tables = ["users", "orders"]
capture_operations = ["INSERT", "UPDATE", "DELETE"]
```
The `pg_replicate` backend requires the `cdc_pg_replicate` feature flag at
build time.
+### Slot Naming
+
+Each CDC connector must use a unique `replication_slot`. Setup accepts any
+pre-existing `test_decoding` slot, so two connectors pointed at the same
+database with the default `replication_slot = "iggy_slot"` will silently
+share one slot. `pg_logical_slot_get_changes` consumes changes on read, so
+each connector only sees a subset of the other's changes instead of erroring.
+Set an explicit, distinct `replication_slot` per connector instance.
+
+### Decommissioning
+
+A replication slot retains WAL for as long as it exists, regardless of
+whether a connector is still consuming it (`max_slot_wal_keep_size` defaults
+to `-1`, i.e. unbounded). Dropping a connector without dropping its slot
+leaves an orphaned slot that accumulates WAL indefinitely and can fill the
+disk. When decommissioning a CDC connector, drop its slot:
+
+```sql
+SELECT pg_drop_replication_slot('iggy_slot');
+```
+
## Example Configs
### Basic Polling (JSON Mode)
@@ -344,7 +360,6 @@ batch_length = 100
[plugin_config]
connection_string = "postgresql://user:pass@localhost:5432/mydb"
mode = "cdc"
-enable_wal_cdc = true
tables = ["users", "orders"]
capture_operations = ["INSERT", "UPDATE"]
```
diff --git a/core/connectors/sources/postgres_source/src/cdc_fixtures.rs
b/core/connectors/sources/postgres_source/src/cdc_fixtures.rs
new file mode 100644
index 000000000..0c7c3b611
--- /dev/null
+++ b/core/connectors/sources/postgres_source/src/cdc_fixtures.rs
@@ -0,0 +1,89 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Real `test_decoding` output rows, captured from
+// pg_logical_slot_get_changes() on postgres:16 with the test_decoding
+// plugin (BEGIN/COMMIT rows omitted, parser never sees them).
+
+pub(crate) const INSERT_SINGLE_ROW_ALL_TYPES: &str = r#"table
public.probe_events: INSERT: id[integer]:2 name[text]:'alice' note[text]:'first
note' amount[numeric]:12.50 active[boolean]:true tags[text[]]:'{a,b}'
payload[jsonb]:'{"k": 1}' created_at[timestamp with time zone]:'2026-07-05
17:58:23.202192+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:nu [...]
+
+pub(crate) const INSERT_WITH_NULLS: &str = r#"table public.probe_events:
INSERT: id[integer]:3 name[text]:'bob' note[text]:null amount[numeric]:null
active[boolean]:null tags[text[]]:null payload[jsonb]:null created_at[timestamp
with time zone]:'2026-07-05 17:58:23.924391+00' small_int[smallint]:null
big_int[bigint]:null real_val[real]:null double_val[double precision]:null
numeric_val[numeric]:null uuid_val[uuid]:null bytea_val[bytea]:null
date_val[date]:null time_val[time without time [...]
+
+pub(crate) const INSERT_MULTI_ROW_SINGLE_STATEMENT: [&str; 2] = [
+ r#"table public.probe_events: INSERT: id[integer]:4 name[text]:'carol'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:24.628431+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
without time zone]:null interval_val[interval]:nul [...]
+ r#"table public.probe_events: INSERT: id[integer]:5 name[text]:'dave'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:24.628431+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
without time zone]:null interval_val[interval]:null [...]
+];
+
+pub(crate) const INSERT_MULTI_STATEMENT_ONE_TRANSACTION: [&str; 2] = [
+ r#"table public.probe_events: INSERT: id[integer]:6 name[text]:'eve'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:25.344503+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
without time zone]:null interval_val[interval]:null [...]
+ r#"table public.probe_events: INSERT: id[integer]:7 name[text]:'frank'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:25.344503+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
without time zone]:null interval_val[interval]:nul [...]
+];
+
+pub(crate) const UPDATE_FULL_ROW: &str = r#"table public.probe_events: UPDATE:
id[integer]:2 name[text]:'alice2' note[text]:'updated' amount[numeric]:99.99
active[boolean]:false tags[text[]]:'{a,b}' payload[jsonb]:'{"k": 1}'
created_at[timestamp with time zone]:'2026-07-05 17:58:23.202192+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:null
double_val[double precision]:null numeric_val[numeric]:null uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:null time_val[t [...]
+
+pub(crate) const UPDATE_SINGLE_COLUMN: &str = r#"table public.probe_events:
UPDATE: id[integer]:3 name[text]:'bob' note[text]:'only note changed'
amount[numeric]:null active[boolean]:null tags[text[]]:null payload[jsonb]:null
created_at[timestamp with time zone]:'2026-07-05 17:58:23.924391+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:null
double_val[double precision]:null numeric_val[numeric]:null uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:null time_val[ [...]
+
+pub(crate) const UPDATE_TO_NULL: &str = r#"table public.probe_events: UPDATE:
id[integer]:2 name[text]:'alice2' note[text]:null amount[numeric]:99.99
active[boolean]:false tags[text[]]:'{a,b}' payload[jsonb]:'{"k": 1}'
created_at[timestamp with time zone]:'2026-07-05 17:58:23.202192+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:null
double_val[double precision]:null numeric_val[numeric]:null uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:null time_val[time wi [...]
+
+// PK change: test_decoding emits the pre-update key separately from the
+// post-update tuple, `old-key: ... new-tuple: ...`, instead of the plain
+// `UPDATE: <cols>` shape every other row here uses.
+pub(crate) const UPDATE_PRIMARY_KEY: &str = r#"table public.probe_events:
UPDATE: old-key: id[integer]:4 new-tuple: id[integer]:1004 name[text]:'carol'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:24.628431+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_va [...]
+
+// DELETE only carries replica-identity columns (here, just the PK), not
+// the full row.
+pub(crate) const DELETE_ROW: &str = "table public.probe_events: DELETE:
id[integer]:5";
+
+pub(crate) const DELETE_MULTIPLE_ROWS: [&str; 2] = [
+ "table public.probe_events: DELETE: id[integer]:6",
+ "table public.probe_events: DELETE: id[integer]:7",
+];
+
+pub(crate) const TRUNCATE_TABLE: &str = "table public.probe_events: TRUNCATE:
(no-flags)";
+
+// Value contains a literal embedded newline and an escaped quote (`''`) -
+// the row spans two lines in the corpus capture.
+pub(crate) const UNICODE_AND_SPECIAL_CHARS: &str = "table public.probe_events:
INSERT: id[integer]:9 name[text]:'unicode' note[text]:'emoji \u{1F680} quote''
backslash\\ newline\nend' amount[numeric]:null active[boolean]:true
tags[text[]]:null payload[jsonb]:null created_at[timestamp with time
zone]:'2026-07-05 17:58:31.874185+00' small_int[smallint]:null
big_int[bigint]:null real_val[real]:null double_val[double precision]:null
numeric_val[numeric]:null uuid_val[uuid]:null bytea_val[byt [...]
+
+pub(crate) const INSERT_EXTENDED_TYPES_NORMAL_VALUES: &str = r#"table
public.probe_events: INSERT: id[integer]:10 name[text]:'extended'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:32.595761+00' small_int[smallint]:42 big_int[bigint]:9000000000
real_val[real]:3.14 double_val[double precision]:2.718281828
numeric_val[numeric]:123.456789
uuid_val[uuid]:'11111111-1111-1111-1111-11111111 [...]
+
+pub(crate) const INSERT_NUMERIC_NAN: &str = r#"table public.probe_events:
INSERT: id[integer]:11 name[text]:'nan_row' note[text]:null
amount[numeric]:null active[boolean]:true tags[text[]]:null payload[jsonb]:null
created_at[timestamp with time zone]:'2026-07-05 17:58:33.30327+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:NaN
double_val[double precision]:NaN numeric_val[numeric]:NaN uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:null time_val[time without tim [...]
+
+pub(crate) const INSERT_NUMERIC_INFINITY: &str = r#"table public.probe_events:
INSERT: id[integer]:12 name[text]:'inf_row' note[text]:null
amount[numeric]:null active[boolean]:true tags[text[]]:null payload[jsonb]:null
created_at[timestamp with time zone]:'2026-07-05 17:58:34.023511+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:Infinity
double_val[double precision]:-Infinity numeric_val[numeric]:Infinity
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null tim [...]
+
+pub(crate) const INSERT_NEGATIVE_AND_BOUNDARY_NUMBERS: &str = r#"table
public.probe_events: INSERT: id[integer]:13 name[text]:'neg_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:34.743797+00' small_int[smallint]:-32768
big_int[bigint]:-9223372036854775808 real_val[real]:-3.14 double_val[double
precision]:-2.71828 numeric_val[numeric]:-999999.9999 uuid_val[uuid]:null
bytea_val[byte [...]
+
+pub(crate) const INSERT_MAX_BOUNDARY_NUMBERS: &str = r#"table
public.probe_events: INSERT: id[integer]:14 name[text]:'max_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:35.473792+00' small_int[smallint]:32767
big_int[bigint]:9223372036854775807 real_val[real]:null double_val[double
precision]:null numeric_val[numeric]:null uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:n [...]
+
+pub(crate) const INSERT_NEGATIVE_ZERO_FLOAT: &str = r#"table
public.probe_events: INSERT: id[integer]:15 name[text]:'negzero_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:36.198193+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:-0 double_val[double precision]:-0 numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
[...]
+
+pub(crate) const INSERT_EMPTY_STRING_VS_NULL: [&str; 2] = [
+ r#"table public.probe_events: INSERT: id[integer]:16
name[text]:'empty_string_row' note[text]:'' amount[numeric]:null
active[boolean]:true tags[text[]]:null payload[jsonb]:null created_at[timestamp
with time zone]:'2026-07-05 17:58:36.931897+00' small_int[smallint]:null
big_int[bigint]:null real_val[real]:null double_val[double precision]:null
numeric_val[numeric]:null uuid_val[uuid]:null bytea_val[bytea]:null
date_val[date]:null time_val[time without time zone]:null interval_val[int [...]
+ r#"table public.probe_events: INSERT: id[integer]:17 name[text]:'null_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:36.931897+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_val[time
without time zone]:null interval_val[interval] [...]
+];
+
+pub(crate) const INSERT_ARRAY_WITH_NULL_ELEMENT: &str = r#"table
public.probe_events: INSERT: id[integer]:18 name[text]:'array_null_elem_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:37.658172+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:nu [...]
+
+pub(crate) const INSERT_EMPTY_ARRAY: &str = r#"table public.probe_events:
INSERT: id[integer]:19 name[text]:'empty_array_row' note[text]:null
amount[numeric]:null active[boolean]:true tags[text[]]:null payload[jsonb]:null
created_at[timestamp with time zone]:'2026-07-05 17:58:38.384294+00'
small_int[smallint]:null big_int[bigint]:null real_val[real]:null
double_val[double precision]:null numeric_val[numeric]:null uuid_val[uuid]:null
bytea_val[bytea]:null date_val[date]:null time_val[time [...]
+
+pub(crate) const UPDATE_ARRAY_COLUMN: &str = r#"table public.probe_events:
UPDATE: id[integer]:10 name[text]:'extended' note[text]:null
amount[numeric]:null active[boolean]:true tags[text[]]:null payload[jsonb]:null
created_at[timestamp with time zone]:'2026-07-05 17:58:32.595761+00'
small_int[smallint]:42 big_int[bigint]:9000000000 real_val[real]:3.14
double_val[double precision]:2.718281828 numeric_val[numeric]:123.456789
uuid_val[uuid]:'11111111-1111-1111-1111-111111111111' bytea_val[ [...]
+
+pub(crate) const INSERT_CHAR_PADDING_VS_VARCHAR: &str = r#"table
public.probe_events: INSERT: id[integer]:20 name[text]:'padding_row'
note[text]:null amount[numeric]:null active[boolean]:true tags[text[]]:null
payload[jsonb]:null created_at[timestamp with time zone]:'2026-07-05
17:58:39.824702+00' small_int[smallint]:null big_int[bigint]:null
real_val[real]:null double_val[double precision]:null numeric_val[numeric]:null
uuid_val[uuid]:null bytea_val[bytea]:null date_val[date]:null time_ [...]
+
+pub(crate) const INSERT_QUOTED_MIXED_CASE_COLUMN: &str = r#"table
public.probe_events: INSERT: id[integer]:21 "createdAt"[timestamp with time
zone]:'2026-07-05 17:58:40.000000+00' "user"[text]:'quoted_row'"#;
+
+pub(crate) const UPDATE_UNCHANGED_TOAST_COLUMN: &str = r#"table
public.probe_events: UPDATE: id[integer]:22 name[text]:'toast_row'
note[text]:unchanged-toast-datum amount[numeric]:null active[boolean]:true
tags[text[]]:null payload[jsonb]:unchanged-toast-datum created_at[timestamp
with time zone]:'2026-07-05 17:58:41.000000+00' small_int[smallint]:null
big_int[bigint]:null real_val[real]:null double_val[double precision]:null
numeric_val[numeric]:null uuid_val[uuid]:null bytea_val[bytea] [...]
diff --git a/core/connectors/sources/postgres_source/src/lib.rs
b/core/connectors/sources/postgres_source/src/lib.rs
index 7593d1786..8deb35516 100644
--- a/core/connectors/sources/postgres_source/src/lib.rs
+++ b/core/connectors/sources/postgres_source/src/lib.rs
@@ -61,12 +61,10 @@ pub struct PostgresSourceConfig {
pub tracking_column: Option<String>,
pub initial_offset: Option<String>,
pub max_connections: Option<u32>,
- pub enable_wal_cdc: Option<bool>,
pub custom_query: Option<String>,
pub snake_case_columns: Option<bool>,
pub include_metadata: Option<bool>,
pub replication_slot: Option<String>,
- pub publication_name: Option<String>,
pub capture_operations: Option<Vec<String>>,
pub cdc_backend: Option<String>,
pub delete_after_read: Option<bool>,
@@ -185,10 +183,13 @@ impl Source for PostgresSource {
self.connect().await?;
+ validate_payload_format(self.config.payload_format.as_deref())?;
+
match self.config.mode.as_str() {
"cdc" => {
+ let backend =
validate_cdc_backend(self.config.cdc_backend.as_deref())?;
+
validate_capture_operations(self.config.capture_operations.as_deref())?;
self.setup_cdc().await?;
- let backend =
self.config.cdc_backend.as_deref().unwrap_or("builtin");
info!(
"PostgreSQL CDC mode enabled (backend: {backend}) for
connector ID: {}",
self.id
@@ -303,10 +304,6 @@ impl PostgresSource {
}
async fn setup_cdc(&self) -> Result<(), Error> {
- if !self.config.enable_wal_cdc.unwrap_or(false) {
- return Ok(());
- }
-
let pool = self.get_pool()?;
let wal_level: String = sqlx::query_scalar("SHOW wal_level")
@@ -320,31 +317,35 @@ impl PostgresSource {
));
}
- let publication_name = self
- .config
- .publication_name
- .as_deref()
- .unwrap_or("iggy_publication");
- let quoted_publication = quote_identifier(publication_name)?;
- let tables_clause = if self.config.tables.is_empty() {
- "FOR ALL TABLES".to_string()
- } else {
- let quoted_tables = self
- .config
- .tables
- .iter()
- .map(|t| quote_qualified_identifier(t))
- .collect::<Result<Vec<_>, _>>()?;
- format!("FOR TABLE {}", quoted_tables.join(", "))
- };
-
- let create_publication_sql =
- format!("CREATE PUBLICATION IF NOT EXISTS {quoted_publication}
{tables_clause}");
+ for table in &self.config.tables {
+ let exists: bool = if let Some((schema, name)) =
table.split_once('.') {
+ sqlx::query_scalar(
+ "SELECT EXISTS (SELECT 1 FROM information_schema.tables \
+ WHERE table_schema = $1 AND table_name = $2)",
+ )
+ .bind(schema)
+ .bind(name)
+ .fetch_one(pool)
+ .await
+ } else {
+ sqlx::query_scalar(
+ "SELECT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_name = $1)",
+ )
+ .bind(table)
+ .fetch_one(pool)
+ .await
+ }
+ .map_err(|e| Error::InitError(format!("Failed to validate table
'{table}': {e}")))?;
- sqlx::query(sqlx::AssertSqlSafe(create_publication_sql))
- .execute(pool)
- .await
- .map_err(|e| Error::InitError(format!("Failed to create
publication: {e}")))?;
+ // Not fatal - the table may just not exist yet (e.g. a migration
+ // runs shortly after startup).
+ if !exists {
+ warn!(
+ "Configured table '{table}' does not exist yet. If this is
unexpected, \
+ check config.tables for typos."
+ );
+ }
+ }
let slot_name = self
.config
@@ -352,40 +353,49 @@ impl PostgresSource {
.as_deref()
.unwrap_or("iggy_slot");
- sqlx::query(
- "SELECT pg_create_logical_replication_slot($1, 'pgoutput') \
- WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots WHERE
slot_name = $1)",
- )
- .bind(slot_name)
- .fetch_optional(pool)
- .await
- .map_err(|e| Error::InitError(format!("Failed to create replication
slot: {e}")))?;
+ let existing_plugin: Option<String> =
+ sqlx::query_scalar("SELECT plugin FROM pg_replication_slots WHERE
slot_name = $1")
+ .bind(slot_name)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| Error::InitError(format!("Failed to check
replication slot: {e}")))?;
- info!("PostgreSQL CDC setup completed. Publication:
{publication_name}, Slot: {slot_name}");
+ match existing_plugin.as_deref() {
+ Some("test_decoding") => {}
+ Some(other) => {
+ return Err(Error::InitError(format!(
+ "Replication slot '{slot_name}' already exists with plugin
'{other}', \
+ expected 'test_decoding' (likely created by an older
version). Drop it \
+ with SELECT pg_drop_replication_slot('{slot_name}') or
set \
+ replication_slot to a new name."
+ )));
+ }
+ None => {
+ // test_decoding ignores publications entirely, so none is
created
+ // here. A future pgoutput-based backend would need one, since
+ // that's the only way to get server-side table filtering under
+ // that plugin.
+ sqlx::query("SELECT pg_create_logical_replication_slot($1,
'test_decoding')")
+ .bind(slot_name)
+ .execute(pool)
+ .await
+ .map_err(|e| {
+ Error::InitError(format!("Failed to create replication
slot: {e}"))
+ })?;
+ }
+ }
+
+ info!("PostgreSQL CDC setup completed. Slot: {slot_name}");
Ok(())
}
async fn poll_cdc(&self) -> Result<Vec<ProducedMessage>, Error> {
- let backend = self.config.cdc_backend.as_deref().unwrap_or("builtin");
- match backend {
+ match self.config.cdc_backend.as_deref().unwrap_or("builtin") {
"builtin" => self.poll_cdc_builtin().await,
- "pg_replicate" => {
- #[cfg(feature = "cdc_pg_replicate")]
- {
- Err(Error::InitError(
- "pg_replicate backend not yet implemented".to_string(),
- ))
- }
- #[cfg(not(feature = "cdc_pg_replicate"))]
- {
- Err(Error::InitError(
- "cdc_backend 'pg_replicate' requested but feature
'cdc_pg_replicate' is not enabled at build time".to_string(),
- ))
- }
- }
- other => Err(Error::InitError(format!(
- "Unsupported cdc_backend '{other}'. Use 'builtin' or
'pg_replicate'"
- ))),
+ "pg_replicate" => Err(Error::InitError(
+ "pg_replicate backend not yet implemented".to_string(),
+ )),
+ other => unreachable!("validate_cdc_backend already rejected
'{other}'"),
}
}
@@ -397,40 +407,53 @@ impl PostgresSource {
.replication_slot
.as_deref()
.unwrap_or("iggy_slot");
- let publication_name = self
- .config
- .publication_name
- .as_deref()
- .unwrap_or("iggy_publication");
let capture_ops = self
.config
.capture_operations
.as_ref()
.map(|ops| ops.iter().map(|s| s.as_str()).collect::<Vec<_>>())
.unwrap_or_else(|| vec!["INSERT", "UPDATE", "DELETE"]);
-
- let logical_repl_sql = format!(
- "SELECT lsn, xid, data FROM
pg_logical_slot_get_changes('{slot_name}', NULL, NULL, 'proto_version', '1',
'publication_names', '{publication_name}')"
- );
-
- // Database I/O without holding the lock
- let rows = sqlx::query(sqlx::AssertSqlSafe(logical_repl_sql))
- .fetch_all(pool)
- .await
- .map_err(|e| {
- error!("Failed to fetch CDC changes: {e}");
- Error::InvalidRecord
- })?;
+ let captured_tables =
+
(!self.config.tables.is_empty()).then_some(self.config.tables.as_slice());
+ let batch_size = self.config.batch_size.unwrap_or(1000) as i32;
+
+ // Database I/O without holding the lock. upto_nchanges is only
+ // checked at transaction-commit boundaries (a single huge transaction
+ // can still exceed it), so this isn't a hard per-call cap - but it
+ // stops the backlog from growing unbounded across many transactions
+ // the way NULL (no limit at all) did.
+ let rows =
+ sqlx::query("SELECT lsn, xid, data FROM
pg_logical_slot_get_changes($1, NULL, $2)")
+ .bind(slot_name)
+ .bind(batch_size)
+ .fetch_all(pool)
+ .await
+ .map_err(|e| {
+ error!("Failed to fetch CDC changes: {e}");
+ Error::InvalidRecord
+ })?;
let mut messages = Vec::new();
for row in rows {
- let data: String = row.try_get("data").map_err(|_|
Error::InvalidRecord)?;
+ let data: String = match row.try_get("data") {
+ Ok(data) => data,
+ Err(e) => {
+ error!("Skipping CDC row with unreadable data column:
{e}");
+ continue;
+ }
+ };
- if let Some(change_record) =
self.parse_logical_replication_message(&data, &capture_ops)
+ if let Some(change_record) =
+ self.parse_logical_replication_message(&data, &capture_ops,
captured_tables)
{
- let payload =
- simd_json::to_vec(&change_record).map_err(|_|
Error::InvalidRecord)?;
+ let payload = match simd_json::to_vec(&change_record) {
+ Ok(payload) => payload,
+ Err(e) => {
+ error!("Skipping CDC row that failed to serialize:
{e}");
+ continue;
+ }
+ };
let message = ProducedMessage {
id: Some(Uuid::new_v4().as_u128()),
@@ -728,99 +751,63 @@ impl PostgresSource {
&self,
data: &str,
capture_ops: &[&str],
+ captured_tables: Option<&[String]>,
) -> Option<DatabaseRecord> {
if data.starts_with("BEGIN") || data.starts_with("COMMIT") {
return None;
}
- if data.starts_with("INSERT:") && capture_ops.contains(&"INSERT") {
- return self.parse_insert_message(data);
- }
-
- if data.starts_with("UPDATE:") && capture_ops.contains(&"UPDATE") {
- return self.parse_update_message(data);
- }
-
- if data.starts_with("DELETE:") && capture_ops.contains(&"DELETE") {
- return self.parse_delete_message(data);
- }
-
- None
- }
+ let rest = data.strip_prefix("table ")?;
+ let (qualified_table, rest) = rest.split_once(": ")?;
+ let (operation, rest) = rest.split_once(": ")?;
- fn parse_insert_message(&self, data: &str) -> Option<DatabaseRecord> {
- if let Some(table_start) = data.find("table ")
- && let Some(colon_pos) = data[table_start..].find(':')
+ if !matches!(operation, "INSERT" | "UPDATE" | "DELETE") ||
!capture_ops.contains(&operation)
{
- let table_part = &data[table_start + 6..table_start + colon_pos];
- let table_name = table_part
- .split('.')
- .next_back()
- .unwrap_or(table_part)
- .to_string();
-
- let data_part = &data[table_start + colon_pos + 1..];
- let parsed_data = parse_record_data(data_part);
-
- return Some(DatabaseRecord {
- table_name,
- operation_type: "INSERT".to_string(),
- timestamp: Utc::now(),
- data: serde_json::Value::Object(parsed_data),
- old_data: None,
- });
+ return None;
}
- None
- }
- fn parse_update_message(&self, data: &str) -> Option<DatabaseRecord> {
- if let Some(table_start) = data.find("table ")
- && let Some(colon_pos) = data[table_start..].find(':')
+ let table_name = unquote_pg_identifier(
+ qualified_table
+ .rsplit('.')
+ .next()
+ .unwrap_or(qualified_table),
+ );
+ let unquoted_qualified_table = qualified_table
+ .split('.')
+ .map(unquote_pg_identifier)
+ .collect::<Vec<_>>()
+ .join(".");
+
+ // test_decoding has no server-side table filter, so Postgres already
+ // sent us every table's changes - config.tables scoping happens here.
+ if let Some(tables) = captured_tables
+ && !tables.iter().any(|t| {
+ if t.contains('.') {
+ t == &unquoted_qualified_table
+ } else {
+ t == &table_name
+ }
+ })
{
- let table_part = &data[table_start + 6..table_start + colon_pos];
- let table_name = table_part
- .split('.')
- .next_back()
- .unwrap_or(table_part)
- .to_string();
-
- let data_part = &data[table_start + colon_pos + 1..];
- let parsed_data = parse_record_data(data_part);
-
- return Some(DatabaseRecord {
- table_name,
- operation_type: "UPDATE".to_string(),
- timestamp: Utc::now(),
- data: serde_json::Value::Object(parsed_data),
- old_data: None,
- });
+ return None;
}
- None
- }
- fn parse_delete_message(&self, data: &str) -> Option<DatabaseRecord> {
- if let Some(table_start) = data.find("table ")
- && let Some(colon_pos) = data[table_start..].find(':')
+ let (old_key, rest) = match rest
+ .strip_prefix("old-key: ")
+ .and_then(|old_key| old_key.split_once("new-tuple: "))
{
- let table_part = &data[table_start + 6..table_start + colon_pos];
- let table_name = table_part
- .split('.')
- .next_back()
- .unwrap_or(table_part)
- .to_string();
-
- let data_part = &data[table_start + colon_pos + 1..];
- let parsed_data = parse_record_data(data_part);
-
- return Some(DatabaseRecord {
- table_name,
- operation_type: "DELETE".to_string(),
- timestamp: Utc::now(),
- data: serde_json::Value::Object(parsed_data),
- old_data: None,
- });
- }
- None
+ Some((old_key, new_tuple)) => (Some(old_key), new_tuple),
+ None => (None, rest),
+ };
+
+ Some(DatabaseRecord {
+ table_name,
+ operation_type: operation.to_string(),
+ timestamp: Utc::now(),
+ data: serde_json::Value::Object(parse_record_columns(rest)),
+ old_data: old_key
+ .map(|old_key|
serde_json::Value::Object(parse_record_columns(old_key))),
+ })
}
fn process_row(
@@ -1497,44 +1484,169 @@ fn to_snake_case(input: &str) -> String {
result
}
-fn parse_record_data(data: &str) -> serde_json::Map<String, serde_json::Value>
{
- let mut result = serde_json::Map::new();
+fn validate_cdc_backend(cdc_backend: Option<&str>) -> Result<&str, Error> {
+ let backend = cdc_backend.unwrap_or("builtin");
+ match backend {
+ "builtin" => Ok(backend),
+ "pg_replicate" => {
+ #[cfg(feature = "cdc_pg_replicate")]
+ {
+ Ok(backend)
+ }
+ #[cfg(not(feature = "cdc_pg_replicate"))]
+ {
+ Err(Error::InitError(
+ "cdc_backend 'pg_replicate' requested but feature
'cdc_pg_replicate' is not enabled at build time".to_string(),
+ ))
+ }
+ }
+ other => Err(Error::InitError(format!(
+ "Unsupported cdc_backend '{other}'. Use 'builtin' or
'pg_replicate'"
+ ))),
+ }
+}
- for part in data.split_whitespace() {
- if let Some(bracket_pos) = part.find('[')
- && let Some(_close_bracket) = part.find(']')
- && let Some(colon_pos) = part.find(':')
- {
- let column_name = &part[..bracket_pos];
- let value_str = &part[colon_pos + 1..];
+fn validate_capture_operations(capture_operations: Option<&[String]>) ->
Result<(), Error> {
+ const VALID: [&str; 3] = ["INSERT", "UPDATE", "DELETE"];
+ let Some(ops) = capture_operations else {
+ return Ok(());
+ };
+ for op in ops {
+ if !VALID.contains(&op.as_str()) {
+ return Err(Error::InitError(format!(
+ "Unsupported capture_operations value '{op}'. Use any of
'INSERT', 'UPDATE', 'DELETE'"
+ )));
+ }
+ }
+ Ok(())
+}
- let cleaned_value = if value_str.starts_with('\'') &&
value_str.ends_with('\'') {
- &value_str[1..value_str.len() - 1]
- } else {
- value_str
- };
+fn validate_payload_format(payload_format: Option<&str>) -> Result<(), Error> {
+ const VALID: [&str; 7] = [
+ "json",
+ "bytea",
+ "raw",
+ "text",
+ "json_direct",
+ "jsonb",
+ "jsonb_direct",
+ ];
+ let Some(fmt) = payload_format else {
+ return Ok(());
+ };
+ if !VALID.contains(&fmt.to_lowercase().as_str()) {
+ return Err(Error::InitError(format!(
+ "Unsupported payload_format '{fmt}'. Use 'json', 'bytea'/'raw',
'text', or 'json_direct'/'jsonb'/'jsonb_direct'"
+ )));
+ }
+ Ok(())
+}
- let value = if let Ok(num) = cleaned_value.parse::<i64>() {
- serde_json::Value::Number(serde_json::Number::from(num))
- } else if let Ok(float) = cleaned_value.parse::<f64>() {
- serde_json::Value::Number(
-
serde_json::Number::from_f64(float).unwrap_or(serde_json::Number::from(0)),
- )
- } else if cleaned_value.eq_ignore_ascii_case("true") {
- serde_json::Value::Bool(true)
- } else if cleaned_value.eq_ignore_ascii_case("false") {
- serde_json::Value::Bool(false)
- } else {
- serde_json::Value::String(cleaned_value.to_string())
- };
+fn unquote_pg_identifier(segment: &str) -> String {
+ match segment.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
+ Some(inner) => inner.replace("\"\"", "\""),
+ None => segment.to_string(),
+ }
+}
- result.insert(column_name.to_string(), value);
+fn parse_record_columns(data: &str) -> serde_json::Map<String,
serde_json::Value> {
+ let mut result = serde_json::Map::new();
+ let bytes = data.as_bytes();
+ let len = bytes.len();
+ let mut pos = 0;
+
+ while pos < len {
+ while pos < len && bytes[pos] == b' ' {
+ pos += 1;
+ }
+ if pos >= len {
+ break;
+ }
+
+ let Some(bracket_offset) = data[pos..].find('[') else {
+ break;
+ };
+ let name_end = pos + bracket_offset;
+ let column_name = unquote_pg_identifier(&data[pos..name_end]);
+
+ let mut depth = 0;
+ let mut type_end = name_end;
+ loop {
+ match bytes.get(type_end) {
+ Some(b'[') => depth += 1,
+ Some(b']') => {
+ depth -= 1;
+ if depth == 0 {
+ break;
+ }
+ }
+ Some(_) => {}
+ None => return result,
+ }
+ type_end += 1;
+ }
+ if bytes.get(type_end + 1) != Some(&b':') {
+ break;
}
+
+ let (value, next_pos) = parse_column_value(data, type_end + 2);
+ result.insert(column_name, value);
+ pos = next_pos;
}
result
}
+fn parse_column_value(data: &str, start: usize) -> (serde_json::Value, usize) {
+ let bytes = data.as_bytes();
+
+ if bytes.get(start) != Some(&b'\'') {
+ let end = data[start..]
+ .find(' ')
+ .map_or(data.len(), |offset| start + offset);
+ return (parse_bare_scalar(&data[start..end]), end);
+ }
+
+ let mut value = String::new();
+ let mut pos = start + 1;
+ while pos < bytes.len() {
+ if bytes[pos] == b'\'' {
+ if bytes.get(pos + 1) == Some(&b'\'') {
+ value.push('\'');
+ pos += 2;
+ continue;
+ }
+ pos += 1;
+ break;
+ }
+ let ch = data[pos..].chars().next().unwrap_or('\u{FFFD}');
+ value.push(ch);
+ pos += ch.len_utf8();
+ }
+ (serde_json::Value::String(value), pos)
+}
+
+fn parse_bare_scalar(token: &str) -> serde_json::Value {
+ // test_decoding emits this sentinel for TOASTed columns the UPDATE didn't
touch;
+ // treating it as null avoids leaking the literal token as a fake column
value.
+ match token {
+ "null" | "unchanged-toast-datum" => serde_json::Value::Null,
+ "true" => serde_json::Value::Bool(true),
+ "false" => serde_json::Value::Bool(false),
+ _ => {
+ if let Ok(i) = token.parse::<i64>() {
+ serde_json::Value::Number(serde_json::Number::from(i))
+ } else if let Ok(f) = token.parse::<f64>()
+ && let Some(num) = serde_json::Number::from_f64(f)
+ {
+ serde_json::Value::Number(num)
+ } else {
+ serde_json::Value::String(token.to_string())
+ }
+ }
+ }
+}
+
async fn with_retry<T, F, Fut>(operation: F, max_retries: u32, delay_ms: u64)
-> Result<T, Error>
where
F: Fn() -> Fut,
@@ -1586,6 +1698,10 @@ fn redact_connection_string(conn_str: &str) -> String {
format!("{preview}***")
}
+#[cfg(test)]
+#[path = "cdc_fixtures.rs"]
+mod cdc_fixtures;
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1600,12 +1716,10 @@ mod tests {
tracking_column: Some("updated_at".to_string()),
initial_offset: None,
max_connections: None,
- enable_wal_cdc: None,
custom_query: None,
snake_case_columns: None,
include_metadata: None,
replication_slot: None,
- publication_name: None,
capture_operations: None,
cdc_backend: None,
delete_after_read: None,
@@ -1703,46 +1817,610 @@ mod tests {
assert!(quote_qualified_identifier(".users").is_err());
}
- #[test]
- fn given_insert_message_should_parse_correctly() {
+ fn cdc_source() -> PostgresSource {
let mut config = test_config();
config.mode = "cdc".to_string();
- let src = PostgresSource::new(1, config, None);
+ PostgresSource::new(1, config, None)
+ }
- let data = "INSERT: table public.users: id[1] name['Alice']
active[true]";
+ #[test]
+ fn given_insert_single_row_all_types_should_parse_correctly() {
+ let src = cdc_source();
let rec = src
- .parse_logical_replication_message(data, &["INSERT"])
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_SINGLE_ROW_ALL_TYPES,
+ &["INSERT"],
+ None,
+ )
.unwrap();
- assert_eq!(rec.table_name, "users");
+
+ assert_eq!(rec.table_name, "probe_events");
assert_eq!(rec.operation_type, "INSERT");
+ assert_eq!(rec.data["id"], serde_json::json!(2));
+ assert_eq!(rec.data["name"], serde_json::json!("alice"));
+ assert_eq!(rec.data["note"], serde_json::json!("first note"));
+ assert_eq!(rec.data["amount"], serde_json::json!(12.50));
+ assert_eq!(rec.data["active"], serde_json::json!(true));
+ assert_eq!(rec.data["tags"], serde_json::json!("{a,b}"));
+ assert_eq!(rec.data["payload"], serde_json::json!(r#"{"k": 1}"#));
+ assert_eq!(rec.data["small_int"], serde_json::Value::Null);
}
#[test]
- fn given_update_message_should_parse_correctly() {
- let mut config = test_config();
- config.mode = "cdc".to_string();
- let src = PostgresSource::new(1, config, None);
+ fn given_insert_with_nulls_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+
.parse_logical_replication_message(cdc_fixtures::INSERT_WITH_NULLS,
&["INSERT"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["name"], serde_json::json!("bob"));
+ assert_eq!(rec.data["note"], serde_json::Value::Null);
+ assert_eq!(rec.data["amount"], serde_json::Value::Null);
+ assert_eq!(rec.data["active"], serde_json::Value::Null);
+ }
- let data = "UPDATE: table public.orders: id[42] total[99.5]";
+ #[test]
+ fn given_multi_row_insert_statement_should_parse_each_row() {
+ let src = cdc_source();
+ for (fixture, name) in cdc_fixtures::INSERT_MULTI_ROW_SINGLE_STATEMENT
+ .iter()
+ .zip(["carol", "dave"])
+ {
+ let rec = src
+ .parse_logical_replication_message(fixture, &["INSERT"], None)
+ .unwrap();
+ assert_eq!(rec.data["name"], serde_json::json!(name));
+ }
+ }
+
+ #[test]
+ fn given_multi_statement_transaction_should_parse_each_insert() {
+ let src = cdc_source();
+ for (fixture, name) in
cdc_fixtures::INSERT_MULTI_STATEMENT_ONE_TRANSACTION
+ .iter()
+ .zip(["eve", "frank"])
+ {
+ let rec = src
+ .parse_logical_replication_message(fixture, &["INSERT"], None)
+ .unwrap();
+ assert_eq!(rec.data["name"], serde_json::json!(name));
+ }
+ }
+
+ #[test]
+ fn given_update_single_column_should_keep_other_columns() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::UPDATE_SINGLE_COLUMN,
+ &["UPDATE"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["note"], serde_json::json!("only note changed"));
+ assert_eq!(rec.data["name"], serde_json::json!("bob"));
+ }
+
+ #[test]
+ fn given_delete_multiple_rows_should_parse_each_row() {
+ let src = cdc_source();
+ for (fixture, id) in cdc_fixtures::DELETE_MULTIPLE_ROWS.iter().zip([6,
7]) {
+ let rec = src
+ .parse_logical_replication_message(fixture, &["DELETE"], None)
+ .unwrap();
+ assert_eq!(rec.data["id"], serde_json::json!(id));
+ }
+ }
+
+ #[test]
+ fn given_update_array_column_should_parse_new_array() {
+ let src = cdc_source();
+ let rec = src
+
.parse_logical_replication_message(cdc_fixtures::UPDATE_ARRAY_COLUMN,
&["UPDATE"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["int_array"], serde_json::json!("{9,8,7}"));
+ }
+
+ #[test]
+ fn given_negative_zero_float_should_parse_as_zero() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_NEGATIVE_ZERO_FLOAT,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["real_val"], serde_json::json!(0));
+ assert_eq!(rec.data["double_val"], serde_json::json!(0));
+ }
+
+ #[test]
+ fn given_quoted_mixed_case_column_should_strip_quotes_from_key() {
+ let src = cdc_source();
let rec = src
- .parse_logical_replication_message(data, &["UPDATE"])
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_QUOTED_MIXED_CASE_COLUMN,
+ &["INSERT"],
+ None,
+ )
.unwrap();
- assert_eq!(rec.table_name, "orders");
+
+ assert_eq!(rec.data["user"], serde_json::json!("quoted_row"));
+ assert!(rec.data.get("createdAt").is_some());
+ assert!(rec.data.get("\"user\"").is_none());
+ }
+
+ #[test]
+ fn given_unknown_cdc_backend_should_fail_validation() {
+ let err = validate_cdc_backend(Some("built-in")).unwrap_err();
+ assert!(matches!(err, Error::InitError(_)));
+ }
+
+ #[test]
+ fn given_no_cdc_backend_should_default_to_builtin() {
+ assert_eq!(validate_cdc_backend(None).unwrap(), "builtin");
+ }
+
+ #[test]
+ fn given_unsupported_capture_operation_should_fail_validation() {
+ let ops = vec!["INSRT".to_string()];
+ let err = validate_capture_operations(Some(&ops)).unwrap_err();
+ assert!(matches!(err, Error::InitError(_)));
+ }
+
+ #[test]
+ fn given_valid_capture_operations_should_pass_validation() {
+ let ops = vec!["INSERT".to_string(), "DELETE".to_string()];
+ assert!(validate_capture_operations(Some(&ops)).is_ok());
+ assert!(validate_capture_operations(None).is_ok());
+ }
+
+ #[test]
+ fn given_unsupported_payload_format_should_fail_validation() {
+ let err = validate_payload_format(Some("btea")).unwrap_err();
+ assert!(matches!(err, Error::InitError(_)));
+ }
+
+ #[test]
+ fn given_valid_payload_format_should_pass_validation() {
+ assert!(validate_payload_format(Some("bytea")).is_ok());
+ assert!(validate_payload_format(Some("JSON")).is_ok());
+ assert!(validate_payload_format(None).is_ok());
+ }
+
+ #[test]
+ fn given_unchanged_toast_datum_should_parse_as_null() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::UPDATE_UNCHANGED_TOAST_COLUMN,
+ &["UPDATE"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["note"], serde_json::Value::Null);
+ assert_eq!(rec.data["payload"], serde_json::Value::Null);
+ assert_eq!(rec.data["name"], serde_json::json!("toast_row"));
+ }
+
+ #[test]
+ fn given_update_full_row_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(cdc_fixtures::UPDATE_FULL_ROW,
&["UPDATE"], None)
+ .unwrap();
+
+ assert_eq!(rec.table_name, "probe_events");
assert_eq!(rec.operation_type, "UPDATE");
+ assert_eq!(rec.data["name"], serde_json::json!("alice2"));
+ assert_eq!(rec.data["amount"], serde_json::json!(99.99));
+ assert_eq!(rec.data["active"], serde_json::json!(false));
}
#[test]
- fn given_delete_message_should_parse_correctly() {
- let mut config = test_config();
- config.mode = "cdc".to_string();
- let src = PostgresSource::new(1, config, None);
+ fn given_update_to_null_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(cdc_fixtures::UPDATE_TO_NULL,
&["UPDATE"], None)
+ .unwrap();
- let data = "DELETE: table public.products: id[7]";
+ assert_eq!(rec.data["note"], serde_json::Value::Null);
+ assert_eq!(rec.data["amount"], serde_json::json!(99.99));
+ }
+
+ #[test]
+ fn given_update_primary_key_should_parse_new_tuple_only() {
+ let src = cdc_source();
+ let rec = src
+
.parse_logical_replication_message(cdc_fixtures::UPDATE_PRIMARY_KEY,
&["UPDATE"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["id"], serde_json::json!(1004));
+ assert_eq!(rec.data["name"], serde_json::json!("carol"));
+ }
+
+ #[test]
+ fn given_delete_row_should_parse_replica_identity_columns() {
+ let src = cdc_source();
let rec = src
- .parse_logical_replication_message(data, &["DELETE"])
+ .parse_logical_replication_message(cdc_fixtures::DELETE_ROW,
&["DELETE"], None)
.unwrap();
- assert_eq!(rec.table_name, "products");
+
+ assert_eq!(rec.table_name, "probe_events");
assert_eq!(rec.operation_type, "DELETE");
+ assert_eq!(rec.data["id"], serde_json::json!(5));
+ assert_eq!(rec.data.as_object().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn given_structurally_broken_rows_should_return_none() {
+ let src = cdc_source();
+ let capture_ops = ["INSERT", "UPDATE", "DELETE"];
+ let bad_rows = [
+ "",
+ "garbage",
+ "table",
+ "table public.probe_events",
+ "table public.probe_events:",
+ "table public.probe_events: INSERT",
+ "table public.probe_events: INSERT:",
+ ];
+
+ for bad_row in bad_rows {
+ assert!(
+ src.parse_logical_replication_message(bad_row, &capture_ops,
None)
+ .is_none(),
+ "Expected None for structurally broken row: {bad_row:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn given_broken_column_syntax_should_not_panic() {
+ let src = cdc_source();
+ let capture_ops = ["INSERT", "UPDATE", "DELETE"];
+ let bad_rows = [
+ "table : INSERT: id[integer]:1",
+ "table public.probe_events: INSERT: id[integer",
+ "table public.probe_events: INSERT: id[integer]",
+ "table public.probe_events: INSERT: id[integer]:",
+ "table public.probe_events: INSERT: id[integer]:'unterminated",
+ ];
+
+ for bad_row in bad_rows {
+ let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ src.parse_logical_replication_message(bad_row, &capture_ops,
None)
+ }));
+ assert!(
+ result.is_ok(),
+ "Parsing must not panic on malformed column data: {bad_row:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn given_truncate_message_should_be_ignored() {
+ let src = cdc_source();
+ assert!(
+ src.parse_logical_replication_message(
+ cdc_fixtures::TRUNCATE_TABLE,
+ &["INSERT", "UPDATE", "DELETE"],
+ None,
+ )
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn given_operation_not_in_capture_ops_should_be_ignored() {
+ let src = cdc_source();
+ assert!(
+ src.parse_logical_replication_message(
+ cdc_fixtures::DELETE_ROW,
+ &["INSERT", "UPDATE"],
+ None
+ )
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn given_schema_qualified_captured_table_should_match_exact_schema_only() {
+ let src = cdc_source();
+ let matching = vec!["public.probe_events".to_string()];
+ let other_schema = vec!["other_schema.probe_events".to_string()];
+
+ assert!(
+ src.parse_logical_replication_message(
+ cdc_fixtures::INSERT_SINGLE_ROW_ALL_TYPES,
+ &["INSERT"],
+ Some(&matching),
+ )
+ .is_some()
+ );
+ assert!(
+ src.parse_logical_replication_message(
+ cdc_fixtures::INSERT_SINGLE_ROW_ALL_TYPES,
+ &["INSERT"],
+ Some(&other_schema),
+ )
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn given_bare_captured_table_should_match_regardless_of_schema() {
+ let src = cdc_source();
+ let bare = vec!["probe_events".to_string()];
+
+ assert!(
+ src.parse_logical_replication_message(
+ cdc_fixtures::INSERT_SINGLE_ROW_ALL_TYPES,
+ &["INSERT"],
+ Some(&bare),
+ )
+ .is_some()
+ );
+ }
+
+ #[test]
+ fn given_mixed_case_table_name_should_unquote_for_table_name_and_filter() {
+ let src = cdc_source();
+ let data = r#"table public."MyTable": INSERT: id[integer]:1
name[text]:'alice'"#;
+
+ let rec = src
+ .parse_logical_replication_message(data, &["INSERT"], None)
+ .unwrap();
+ assert_eq!(rec.table_name, "MyTable");
+
+ let bare = vec!["MyTable".to_string()];
+ assert!(
+ src.parse_logical_replication_message(data, &["INSERT"],
Some(&bare))
+ .is_some(),
+ "Unquoted config entry must match the unquoted table name"
+ );
+
+ let qualified = vec!["public.MyTable".to_string()];
+ assert!(
+ src.parse_logical_replication_message(data, &["INSERT"],
Some(&qualified))
+ .is_some(),
+ "Schema-qualified config entry must match against the unquoted
qualified name"
+ );
+
+ let wrong_case = vec!["mytable".to_string()];
+ assert!(
+ src.parse_logical_replication_message(data, &["INSERT"],
Some(&wrong_case))
+ .is_none(),
+ "Postgres identifiers are case-sensitive once quoted - must not
fuzzy-match"
+ );
+ }
+
+ #[test]
+ fn given_quoted_identifier_with_embedded_quote_should_unescape() {
+ let src = cdc_source();
+ let data = r#"table public."Weird""Table": INSERT: id[integer]:1"#;
+
+ let rec = src
+ .parse_logical_replication_message(data, &["INSERT"], None)
+ .unwrap();
+ assert_eq!(rec.table_name, "Weird\"Table");
+ }
+
+ #[test]
+ fn
given_new_tuple_substring_in_value_should_not_truncate_ordinary_update() {
+ let src = cdc_source();
+ let data = "table public.probe_events: UPDATE: id[integer]:2 \
+ note[text]:'see new-tuple: format docs'
amount[numeric]:99.99";
+
+ let rec = src
+ .parse_logical_replication_message(data, &["UPDATE"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["id"], serde_json::json!(2));
+ assert_eq!(
+ rec.data["note"],
+ serde_json::json!("see new-tuple: format docs")
+ );
+ assert_eq!(rec.data["amount"], serde_json::json!(99.99));
+ }
+
+ #[test]
+ fn given_old_key_section_should_populate_old_data() {
+ let src = cdc_source();
+ let data = "table public.probe_events: UPDATE: old-key: id[integer]:1 \
+ new-tuple: id[integer]:2 amount[numeric]:99.99";
+
+ let rec = src
+ .parse_logical_replication_message(data, &["UPDATE"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["id"], serde_json::json!(2));
+ assert_eq!(rec.data["amount"], serde_json::json!(99.99));
+ assert_eq!(rec.old_data.unwrap()["id"], serde_json::json!(1));
+ }
+
+ #[test]
+ fn given_no_old_key_section_should_leave_old_data_none() {
+ let src = cdc_source();
+ let data = "table public.probe_events: INSERT: id[integer]:1";
+
+ let rec = src
+ .parse_logical_replication_message(data, &["INSERT"], None)
+ .unwrap();
+
+ assert!(rec.old_data.is_none());
+ }
+
+ #[test]
+ fn given_unicode_and_escaped_quote_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::UNICODE_AND_SPECIAL_CHARS,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(
+ rec.data["note"],
+ serde_json::json!("emoji \u{1F680} quote' backslash\\
newline\nend")
+ );
+ }
+
+ #[test]
+ fn given_extended_types_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_EXTENDED_TYPES_NORMAL_VALUES,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["small_int"], serde_json::json!(42));
+ assert_eq!(rec.data["big_int"], serde_json::json!(9000000000i64));
+ assert_eq!(
+ rec.data["real_val"],
+ serde_json::json!("3.14".parse::<f64>().unwrap())
+ );
+ assert_eq!(
+ rec.data["uuid_val"],
+ serde_json::json!("11111111-1111-1111-1111-111111111111")
+ );
+ assert_eq!(rec.data["bytea_val"], serde_json::json!("\\xdeadbeef"));
+ assert_eq!(rec.data["date_val"], serde_json::json!("2024-01-15"));
+ assert_eq!(rec.data["int_array"], serde_json::json!("{1,2,3}"));
+ assert_eq!(rec.data["char_val"], serde_json::json!("ab "));
+ }
+
+ #[test]
+ fn given_nan_should_parse_as_string() {
+ let src = cdc_source();
+ let rec = src
+
.parse_logical_replication_message(cdc_fixtures::INSERT_NUMERIC_NAN,
&["INSERT"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["real_val"], serde_json::json!("NaN"));
+ assert_eq!(rec.data["numeric_val"], serde_json::json!("NaN"));
+ }
+
+ #[test]
+ fn given_infinity_should_parse_as_string() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_NUMERIC_INFINITY,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["real_val"], serde_json::json!("Infinity"));
+ assert_eq!(rec.data["double_val"], serde_json::json!("-Infinity"));
+ }
+
+ #[test]
+ fn given_negative_and_boundary_numbers_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_NEGATIVE_AND_BOUNDARY_NUMBERS,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["small_int"], serde_json::json!(-32768));
+ assert_eq!(
+ rec.data["big_int"],
+ serde_json::json!(-9223372036854775808i64)
+ );
+ }
+
+ #[test]
+ fn given_max_boundary_numbers_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_MAX_BOUNDARY_NUMBERS,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["small_int"], serde_json::json!(32767));
+ assert_eq!(
+ rec.data["big_int"],
+ serde_json::json!(9223372036854775807i64)
+ );
+ }
+
+ #[test]
+ fn given_empty_string_vs_null_should_be_distinct() {
+ let src = cdc_source();
+ let empty_row = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_EMPTY_STRING_VS_NULL[0],
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+ let null_row = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_EMPTY_STRING_VS_NULL[1],
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(empty_row.data["note"], serde_json::json!(""));
+ assert_eq!(null_row.data["note"], serde_json::Value::Null);
+ }
+
+ #[test]
+ fn given_array_with_null_element_should_parse_as_raw_string() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_ARRAY_WITH_NULL_ELEMENT,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["int_array"], serde_json::json!("{1,2,NULL,4}"));
+ }
+
+ #[test]
+ fn given_empty_array_should_parse_correctly() {
+ let src = cdc_source();
+ let rec = src
+
.parse_logical_replication_message(cdc_fixtures::INSERT_EMPTY_ARRAY,
&["INSERT"], None)
+ .unwrap();
+
+ assert_eq!(rec.data["int_array"], serde_json::json!("{}"));
+ }
+
+ #[test]
+ fn given_char_padding_vs_varchar_should_preserve_padding() {
+ let src = cdc_source();
+ let rec = src
+ .parse_logical_replication_message(
+ cdc_fixtures::INSERT_CHAR_PADDING_VS_VARCHAR,
+ &["INSERT"],
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(rec.data["char_val"], serde_json::json!("ab "));
+ assert_eq!(rec.data["varchar_val"], serde_json::json!("ab"));
}
#[test]
diff --git a/core/integration/tests/connectors/fixtures/mod.rs
b/core/integration/tests/connectors/fixtures/mod.rs
index 885867de9..6d75fbf97 100644
--- a/core/integration/tests/connectors/fixtures/mod.rs
+++ b/core/integration/tests/connectors/fixtures/mod.rs
@@ -74,8 +74,9 @@ pub use mongodb::{
};
pub use postgres::{
PostgresOps, PostgresSinkByteaFixture, PostgresSinkFixture,
PostgresSinkJsonFixture,
- PostgresSourceByteaFixture, PostgresSourceDeleteFixture,
PostgresSourceJsonFixture,
- PostgresSourceJsonbFixture, PostgresSourceMarkFixture, PostgresSourceOps,
+ PostgresSourceByteaFixture, PostgresSourceCdcFixture,
PostgresSourceDeleteFixture,
+ PostgresSourceJsonFixture, PostgresSourceJsonbFixture,
PostgresSourceMarkFixture,
+ PostgresSourceOps,
};
pub use quickwit::{QuickwitFixture, QuickwitOps, QuickwitPreCreatedFixture};
pub use s3::{S3SinkFixture, S3SinkOps, S3SinkRotationFixture};
diff --git a/core/integration/tests/connectors/fixtures/postgres/cdc.rs
b/core/integration/tests/connectors/fixtures/postgres/cdc.rs
new file mode 100644
index 000000000..88d69a054
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/postgres/cdc.rs
@@ -0,0 +1,206 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::container::{
+ DEFAULT_TEST_STREAM, DEFAULT_TEST_TOPIC, ENV_SOURCE_CONNECTION_STRING,
+ ENV_SOURCE_INCLUDE_METADATA, ENV_SOURCE_MODE, ENV_SOURCE_PATH,
ENV_SOURCE_POLL_INTERVAL,
+ ENV_SOURCE_STREAMS_0_SCHEMA, ENV_SOURCE_STREAMS_0_STREAM,
ENV_SOURCE_STREAMS_0_TOPIC,
+ ENV_SOURCE_TABLES, ENV_SOURCE_TRACKING_COLUMN, PostgresContainer,
PostgresOps,
+ PostgresSourceOps,
+};
+use async_trait::async_trait;
+use integration::harness::{TestBinaryError, TestFixture};
+use sqlx::{Pool, Postgres};
+use std::collections::HashMap;
+
+/// PostgreSQL source fixture for CDC (WAL logical decoding) mode.
+///
+/// Starts the container with `wal_level = logical` so the connector's
+/// `setup_cdc` can create a real `test_decoding` replication slot.
+pub struct PostgresSourceCdcFixture {
+ container: PostgresContainer,
+}
+
+impl PostgresOps for PostgresSourceCdcFixture {
+ fn container(&self) -> &PostgresContainer {
+ &self.container
+ }
+}
+
+impl PostgresSourceOps for PostgresSourceCdcFixture {
+ fn table_name(&self) -> &str {
+ Self::TABLE
+ }
+}
+
+impl PostgresSourceCdcFixture {
+ const TABLE: &'static str = "cdc_events";
+ const UNTRACKED_TABLE: &'static str = "cdc_events_untracked";
+
+ pub async fn create_table(&self, pool: &Pool<Postgres>) {
+ let query = format!(
+ "CREATE TABLE IF NOT EXISTS {} (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ count INTEGER NOT NULL,
+ amount DOUBLE PRECISION NOT NULL,
+ active BOOLEAN NOT NULL,
+ timestamp BIGINT NOT NULL,
+ tag CHAR(10) NOT NULL
+ )",
+ Self::TABLE
+ );
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to create table: {e}"));
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub async fn insert_row(
+ &self,
+ pool: &Pool<Postgres>,
+ id: i32,
+ name: &str,
+ count: i32,
+ amount: f64,
+ active: bool,
+ timestamp: i64,
+ ) {
+ let tag = format!("{:<10}", format!("tag_{id}"));
+ let query = format!(
+ "INSERT INTO {} (id, name, count, amount, active, timestamp, tag)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
+ Self::TABLE
+ );
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(id)
+ .bind(name)
+ .bind(count)
+ .bind(amount)
+ .bind(active)
+ .bind(timestamp)
+ .bind(&tag)
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to insert row: {e}"));
+ }
+
+ pub async fn update_row(&self, pool: &Pool<Postgres>, id: i32, new_count:
i32) {
+ let query = format!("UPDATE {} SET count = $1 WHERE id = $2",
Self::TABLE);
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(new_count)
+ .bind(id)
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to update row: {e}"));
+ }
+
+ pub async fn update_primary_key(&self, pool: &Pool<Postgres>, old_id: i32,
new_id: i32) {
+ let query = format!("UPDATE {} SET id = $1 WHERE id = $2",
Self::TABLE);
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(new_id)
+ .bind(old_id)
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to update primary key: {e}"));
+ }
+
+ pub async fn delete_row(&self, pool: &Pool<Postgres>, id: i32) {
+ let query = format!("DELETE FROM {} WHERE id = $1", Self::TABLE);
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(id)
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to delete row: {e}"));
+ }
+
+ pub async fn insert_row_rolled_back(&self, pool: &Pool<Postgres>, id: i32,
name: &str) {
+ let mut tx = pool
+ .begin()
+ .await
+ .unwrap_or_else(|e| panic!("Failed to begin transaction: {e}"));
+ let query = format!(
+ "INSERT INTO {} (id, name, count, amount, active, timestamp, tag) \
+ VALUES ($1, $2, 0, 0.0, true, 0, 'rollback ')",
+ Self::TABLE
+ );
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(id)
+ .bind(name)
+ .execute(&mut *tx)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to insert row inside
transaction: {e}"));
+ tx.rollback()
+ .await
+ .unwrap_or_else(|e| panic!("Failed to roll back transaction:
{e}"));
+ }
+
+ pub async fn create_untracked_table(&self, pool: &Pool<Postgres>) {
+ let query = format!(
+ "CREATE TABLE IF NOT EXISTS {} (id SERIAL PRIMARY KEY, name
VARCHAR(255) NOT NULL)",
+ Self::UNTRACKED_TABLE
+ );
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to create untracked table:
{e}"));
+ }
+
+ pub async fn insert_untracked_row(&self, pool: &Pool<Postgres>, name:
&str) {
+ let query = format!("INSERT INTO {} (name) VALUES ($1)",
Self::UNTRACKED_TABLE);
+ sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(name)
+ .execute(pool)
+ .await
+ .unwrap_or_else(|e| panic!("Failed to insert untracked row: {e}"));
+ }
+}
+
+#[async_trait]
+impl TestFixture for PostgresSourceCdcFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let container =
PostgresContainer::start_with_logical_replication().await?;
+ Ok(Self { container })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ let mut envs = HashMap::new();
+ envs.insert(
+ ENV_SOURCE_CONNECTION_STRING.to_string(),
+ self.container.connection_string.clone(),
+ );
+ envs.insert(ENV_SOURCE_MODE.to_string(), "cdc".to_string());
+ envs.insert(ENV_SOURCE_TABLES.to_string(), format!("[{}]",
Self::TABLE));
+ envs.insert(ENV_SOURCE_TRACKING_COLUMN.to_string(), "id".to_string());
+ envs.insert(ENV_SOURCE_INCLUDE_METADATA.to_string(),
"true".to_string());
+ envs.insert(
+ ENV_SOURCE_STREAMS_0_STREAM.to_string(),
+ DEFAULT_TEST_STREAM.to_string(),
+ );
+ envs.insert(
+ ENV_SOURCE_STREAMS_0_TOPIC.to_string(),
+ DEFAULT_TEST_TOPIC.to_string(),
+ );
+ envs.insert(ENV_SOURCE_STREAMS_0_SCHEMA.to_string(),
"json".to_string());
+ envs.insert(ENV_SOURCE_POLL_INTERVAL.to_string(), "50ms".to_string());
+ envs.insert(
+ ENV_SOURCE_PATH.to_string(),
+ "../../target/debug/libiggy_connector_postgres_source".to_string(),
+ );
+ envs
+ }
+}
diff --git a/core/integration/tests/connectors/fixtures/postgres/container.rs
b/core/integration/tests/connectors/fixtures/postgres/container.rs
index 642341a12..508607f0c 100644
--- a/core/integration/tests/connectors/fixtures/postgres/container.rs
+++ b/core/integration/tests/connectors/fixtures/postgres/container.rs
@@ -22,7 +22,7 @@ use sqlx::{Pool, Postgres};
use crate::connectors::fixtures;
use testcontainers_modules::{
postgres,
- testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner},
+ testcontainers::{ContainerAsync, ContainerRequest, ImageExt,
runners::AsyncRunner},
};
pub(super) const POSTGRES_PORT: u16 = 5432;
@@ -68,6 +68,7 @@ pub(super) const ENV_SOURCE_PROCESSED_COLUMN: &str =
"IGGY_CONNECTORS_SOURCE_POSTGRES_PLUGIN_CONFIG_PROCESSED_COLUMN";
pub(super) const ENV_SOURCE_INCLUDE_METADATA: &str =
"IGGY_CONNECTORS_SOURCE_POSTGRES_PLUGIN_CONFIG_INCLUDE_METADATA";
+pub(super) const ENV_SOURCE_MODE: &str =
"IGGY_CONNECTORS_SOURCE_POSTGRES_PLUGIN_CONFIG_MODE";
pub(super) const DEFAULT_TEST_STREAM: &str = "test_stream";
pub(super) const DEFAULT_TEST_TOPIC: &str = "test_topic";
@@ -128,7 +129,23 @@ pub struct PostgresContainer {
impl PostgresContainer {
pub(super) async fn start() -> Result<Self, TestBinaryError> {
- let container = postgres::Postgres::default()
+ Self::start_with_image(postgres::Postgres::default().into()).await
+ }
+
+ pub(super) async fn start_with_logical_replication() -> Result<Self,
TestBinaryError> {
+ Self::start_with_image(postgres::Postgres::default().with_cmd([
+ "-c",
+ "wal_level=logical",
+ "-c",
+ "fsync=off",
+ ]))
+ .await
+ }
+
+ async fn start_with_image(
+ image: ContainerRequest<postgres::Postgres>,
+ ) -> Result<Self, TestBinaryError> {
+ let container = image
.with_container_name(fixtures::unique_container_name("postgres"))
.start()
.await
diff --git a/core/integration/tests/connectors/fixtures/postgres/mod.rs
b/core/integration/tests/connectors/fixtures/postgres/mod.rs
index 327012672..cca22e6e3 100644
--- a/core/integration/tests/connectors/fixtures/postgres/mod.rs
+++ b/core/integration/tests/connectors/fixtures/postgres/mod.rs
@@ -15,10 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+mod cdc;
mod container;
mod sink;
mod source;
+pub use cdc::PostgresSourceCdcFixture;
pub use container::{PostgresOps, PostgresSourceOps};
pub use sink::{PostgresSinkByteaFixture, PostgresSinkFixture,
PostgresSinkJsonFixture};
pub use source::{
diff --git
a/core/integration/tests/connectors/postgres/cdc_restart_connectors/config.toml
b/core/integration/tests/connectors/postgres/cdc_restart_connectors/config.toml
new file mode 100644
index 000000000..1ea3757f2
--- /dev/null
+++
b/core/integration/tests/connectors/postgres/cdc_restart_connectors/config.toml
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+type = "source"
+key = "postgres"
+enabled = true
+version = 0
+name = "Postgres source"
+path = "../../target/release/libiggy_connector_postgres_source"
+verbose = false
+
+[[streams]]
+stream = "user_events"
+topic = "users"
+schema = "json"
+batch_length = 100
+
+[plugin_config]
+connection_string = "postgresql://user:pass@localhost:5432/database"
+mode = "polling"
+tables = ["users", "orders"]
+poll_interval = "1s"
+batch_size = 1000
+tracking_column = "id"
+initial_offset = "0"
+max_connections = 10
+snake_case_columns = false
+include_metadata = true
diff --git a/core/integration/tests/connectors/postgres/mod.rs
b/core/integration/tests/connectors/postgres/mod.rs
index 6cb4e679e..ee992b36b 100644
--- a/core/integration/tests/connectors/postgres/mod.rs
+++ b/core/integration/tests/connectors/postgres/mod.rs
@@ -17,6 +17,7 @@
mod postgres_sink;
mod postgres_source;
+mod postgres_source_cdc;
mod restart;
use crate::connectors::TestMessage;
diff --git a/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
b/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
new file mode 100644
index 000000000..b9a485e83
--- /dev/null
+++ b/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
@@ -0,0 +1,584 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{POLL_ATTEMPTS, POLL_INTERVAL_MS};
+use crate::connectors::create_test_messages;
+use crate::connectors::fixtures::{PostgresOps, PostgresSourceCdcFixture,
PostgresSourceOps};
+use iggy::prelude::IggyClient;
+use iggy_common::MessageClient;
+use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_connector_sdk::api::{ConnectorStatus, SourceInfoResponse};
+use integration::harness::seeds;
+use integration::iggy_harness;
+use reqwest::Client;
+use serde::Deserialize;
+use std::time::Duration;
+use tokio::time::sleep;
+
+const API_KEY: &str = "test-api-key";
+const SOURCE_KEY: &str = "postgres";
+const DEFAULT_SLOT: &str = "iggy_slot";
+
+#[derive(Debug, Deserialize)]
+struct CdcRecord {
+ table_name: String,
+ operation_type: String,
+ data: serde_json::Value,
+}
+
+async fn poll_cdc_records(
+ client: &IggyClient,
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+ consumer_id: &Identifier,
+ want: usize,
+) -> Vec<CdcRecord> {
+ let mut received = Vec::new();
+ for _ in 0..POLL_ATTEMPTS {
+ if let Ok(polled) = client
+ .poll_messages(
+ stream_id,
+ topic_id,
+ None,
+ &Consumer::new(consumer_id.clone()),
+ &PollingStrategy::next(),
+ 10,
+ true,
+ )
+ .await
+ {
+ for msg in polled.messages {
+ if let Ok(record) =
serde_json::from_slice::<CdcRecord>(&msg.payload) {
+ received.push(record);
+ }
+ }
+ if received.len() >= want {
+ break;
+ }
+ }
+ sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+ }
+ received
+}
+
+// End-to-end CDC coverage against a real wal_level=logical container:
+// INSERT, UPDATE, PK-changing UPDATE, DELETE, a rolled-back transaction
+// (must produce nothing), an untracked table (must be filtered out), a
+// 25-row sustained batch, and a final INSERT proving the slot/connector
+// are still healthy after all of the above.
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/postgres/source.toml")),
+ seed = seeds::connector_stream
+)]
+async fn cdc_source_captures_insert_update_delete(
+ harness: &TestHarness,
+ fixture: PostgresSourceCdcFixture,
+) {
+ const BATCH_SIZE: usize = 25;
+
+ let client = harness.root_client().await.unwrap();
+ let pool = fixture.create_pool().await.expect("Failed to create pool");
+ fixture.create_table(&pool).await;
+ fixture.create_untracked_table(&pool).await;
+
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+ let consumer_id: Identifier = "cdc_test_consumer".try_into().unwrap();
+
+ // INSERT: basic capture, full column fidelity
(id/name/count/amount/active).
+ let [msg] = create_test_messages(1).try_into().unwrap();
+ fixture
+ .insert_row(
+ &pool,
+ msg.id as i32,
+ &msg.name,
+ msg.count as i32,
+ msg.amount,
+ msg.active,
+ msg.timestamp,
+ )
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(received.len(), 1, "Expected 1 INSERT message");
+ assert_eq!(received[0].table_name, fixture.table_name());
+ assert_eq!(received[0].operation_type, "INSERT");
+ assert_eq!(received[0].data["id"], serde_json::json!(msg.id));
+ assert_eq!(received[0].data["name"], serde_json::json!(msg.name));
+ assert_eq!(received[0].data["count"], serde_json::json!(msg.count));
+ assert_eq!(received[0].data["amount"].as_f64(), Some(msg.amount));
+ assert_eq!(received[0].data["active"], serde_json::json!(msg.active));
+
+ // UPDATE: non-key column change.
+ let updated_count = msg.count + 1;
+ fixture
+ .update_row(&pool, msg.id as i32, updated_count as i32)
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(received.len(), 1, "Expected 1 UPDATE message");
+ assert_eq!(received[0].operation_type, "UPDATE");
+ assert_eq!(received[0].data["id"], serde_json::json!(msg.id));
+ assert_eq!(received[0].data["count"], serde_json::json!(updated_count));
+
+ // UPDATE: primary key change - test_decoding emits "old-key: ...
new-tuple: ...",
+ // the parser must report the new-tuple's id, not the old one.
+ let new_id = msg.id as i32 + 1000;
+ fixture
+ .update_primary_key(&pool, msg.id as i32, new_id)
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(received.len(), 1, "Expected 1 UPDATE message for PK change");
+ assert_eq!(received[0].operation_type, "UPDATE");
+ assert_eq!(
+ received[0].data["id"],
+ serde_json::json!(new_id),
+ "PK-changing UPDATE must report the new-tuple id, not the old-key one"
+ );
+ assert_eq!(received[0].data["name"], serde_json::json!(msg.name));
+
+ // DELETE: only replica-identity columns (here, just id) are present.
+ fixture.delete_row(&pool, new_id).await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(received.len(), 1, "Expected 1 DELETE message");
+ assert_eq!(received[0].operation_type, "DELETE");
+ assert_eq!(received[0].data["id"], serde_json::json!(new_id));
+
+ // Negative cases, verified together with the batch below: a rolled-back
+ // transaction must never reach test_decoding, and a table outside
+ // config.tables must be dropped by the client-side filter.
+ fixture
+ .insert_row_rolled_back(&pool, 9000, "should_not_appear")
+ .await;
+ fixture.insert_untracked_row(&pool, "not_configured").await;
+
+ // Sustained batch: BATCH_SIZE separate INSERT rows, asserting order and
+ // full data fidelity hold under a longer run, and that neither the
+ // rolled-back row nor the untracked-table row leaked into the output.
+ let batch = create_test_messages(BATCH_SIZE);
+ for row in &batch {
+ fixture
+ .insert_row(
+ &pool,
+ row.id as i32,
+ &row.name,
+ row.count as i32,
+ row.amount,
+ row.active,
+ row.timestamp,
+ )
+ .await;
+ }
+
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, BATCH_SIZE).await;
+ assert_eq!(
+ received.len(),
+ BATCH_SIZE,
+ "Expected exactly the {BATCH_SIZE} batch of INSERT rows - the
rolled-back row and the \
+ untracked-table row must not appear"
+ );
+ for (i, record) in received.iter().enumerate() {
+ assert_eq!(record.operation_type, "INSERT");
+ assert_eq!(record.table_name, fixture.table_name());
+ assert_eq!(
+ record.data["id"],
+ serde_json::json!(batch[i].id),
+ "Out-of-order or missing row at index {i}"
+ );
+ assert_eq!(record.data["name"], serde_json::json!(batch[i].name));
+ }
+
+ // Final sanity check: the slot/connector must still be healthy after the
+ // earlier PK-changing UPDATE and the sustained batch, not left in some
+ // degraded state that silently swallows further changes.
+ let final_id = new_id + BATCH_SIZE as i32 + 1;
+ fixture
+ .insert_row(
+ &pool,
+ final_id,
+ "after_pk_change",
+ 1,
+ 1.0,
+ true,
+ msg.timestamp,
+ )
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(
+ received.len(),
+ 1,
+ "Expected the final INSERT after the PK change to still be captured"
+ );
+ assert_eq!(received[0].operation_type, "INSERT");
+ assert_eq!(received[0].data["id"], serde_json::json!(final_id));
+ assert_eq!(
+ received[0].data["name"],
+ serde_json::json!("after_pk_change")
+ );
+
+ pool.close().await;
+}
+
+async fn wait_for_source_status(
+ http: &Client,
+ api_url: &str,
+ expected: ConnectorStatus,
+) -> SourceInfoResponse {
+ for _ in 0..POLL_ATTEMPTS {
+ if let Ok(resp) = http
+ .get(format!("{api_url}/sources/{SOURCE_KEY}"))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ && let Ok(info) = resp.json::<SourceInfoResponse>().await
+ && info.status == expected
+ {
+ return info;
+ }
+ sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+ }
+ panic!("Source connector did not reach {expected:?} status in time");
+}
+
+async fn get_active_source_config(http: &Client, api_url: &str) ->
serde_json::Value {
+ http.get(format!("{api_url}/sources/{SOURCE_KEY}/configs/active"))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to fetch active source config")
+ .json()
+ .await
+ .expect("Failed to parse active source config")
+}
+
+// CreateSourceConfig has no key/version/active fields - strip what GET
+// .../configs/active added before POSTing the payload back as a new version.
+async fn push_source_config(http: &Client, api_url: &str, mut config:
serde_json::Value) -> u64 {
+ if let Some(obj) = config.as_object_mut() {
+ obj.remove("key");
+ obj.remove("version");
+ obj.remove("active");
+ }
+ let resp = http
+ .post(format!("{api_url}/sources/{SOURCE_KEY}/configs"))
+ .header("api-key", API_KEY)
+ .json(&config)
+ .send()
+ .await
+ .expect("Failed to call create-config endpoint");
+ assert!(
+ resp.status().is_success(),
+ "Failed to create source config version, got {}",
+ resp.status()
+ );
+ let created: serde_json::Value = resp.json().await.expect("Failed to parse
created config");
+ created["version"]
+ .as_u64()
+ .expect("Created config response missing version")
+}
+
+async fn activate_source_config(http: &Client, api_url: &str, version: u64) {
+ let resp = http
+ .put(format!("{api_url}/sources/{SOURCE_KEY}/configs/active"))
+ .header("api-key", API_KEY)
+ .json(&serde_json::json!({ "version": version }))
+ .send()
+ .await
+ .expect("Failed to call activate-config endpoint");
+ assert!(
+ resp.status().is_success(),
+ "Failed to activate source config version {version}, got {}",
+ resp.status()
+ );
+}
+
+async fn restart_source_expect(http: &Client, api_url: &str, should_succeed:
bool, context: &str) {
+ let resp = http
+ .post(format!("{api_url}/sources/{SOURCE_KEY}/restart"))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to call restart endpoint");
+ assert_eq!(
+ resp.status().is_success(),
+ should_succeed,
+ "{context}, got {}",
+ resp.status()
+ );
+}
+
+// The local config provider globs every *.toml under config_dir on startup
+// (see LocalConnectorsConfigProvider::init), and config_dir here is the real,
+// shared postgres_source crate directory - so any version this test pushes
+// must be deleted again, or it leaks into every other process that later
+// points at that same directory.
+async fn delete_source_config_version(http: &Client, api_url: &str, version:
u64) {
+ let resp = http
+ .delete(format!(
+ "{api_url}/sources/{SOURCE_KEY}/configs?version={version}"
+ ))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to call delete-config endpoint");
+ assert!(
+ resp.status().is_success(),
+ "Failed to delete source config version {version}, got {}",
+ resp.status()
+ );
+}
+
+// The connector calls pg_logical_slot_get_changes on a fixed poll interval and
+// briefly holds the slot active during each call. A drop landing in that
window
+// gets ERROR 55006 (slot is active for PID ...), so retry past transient hits
+// instead of dropping while the poller is guaranteed stopped.
+const PG_OBJECT_IN_USE: &str = "55006";
+
+async fn drop_replication_slot_retrying(pool: &sqlx::PgPool, slot: &str) {
+ for attempt in 0..POLL_ATTEMPTS {
+ match sqlx::query("SELECT pg_drop_replication_slot($1)")
+ .bind(slot)
+ .execute(pool)
+ .await
+ {
+ Ok(_) => return,
+ Err(sqlx::Error::Database(ref db_err))
+ if attempt + 1 < POLL_ATTEMPTS
+ && db_err.code().as_deref() == Some(PG_OBJECT_IN_USE) =>
+ {
+ sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+ }
+ Err(e) => panic!("Failed to drop replication slot {slot}: {e}"),
+ }
+ }
+}
+
+// Three restart scenarios against one container, run in sequence:
+// 1. Simulates upgrading from the old broken version, which left a slot
+// behind created with the pgoutput plugin. setup_cdc must refuse to
+// silently reuse a mismatched slot (which used to fail forever on
+// every poll instead) - it should fail loudly at startup, and recover
+// cleanly once the operator drops the bad slot and restarts.
+// 2. Invalid plugin_config fields (capture_operations, payload_format).
+// open() must reject each at restart instead of leaving a Running
+// connector that silently drops every change or emits wrong data - the
+// same silent-death shape as the slot mismatch above. Config is fixed
+// one field at a time until restart succeeds and CDC resumes.
+// 3. Changes written while the connector is down (the slot retains WAL
+// regardless of consumer state) - not the at-least-once crash window
+// where the slot has already been consumed but send/state-persist
+// hasn't happened yet. That gap remains open until the slot-peek/LSN
+// work lands.
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/postgres/source_cdc_restart.toml")),
+ seed = seeds::connector_stream
+)]
+async fn cdc_source_recovers_from_slot_mismatch_and_restart(
+ harness: &mut TestHarness,
+ fixture: PostgresSourceCdcFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let pool = fixture.create_pool().await.expect("Failed to create pool");
+ fixture.create_table(&pool).await;
+
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+ let consumer_id: Identifier = "cdc_restart_consumer".try_into().unwrap();
+
+ let api_url = harness
+ .connectors_runtime()
+ .expect("connector runtime should be available")
+ .http_url();
+ let http = Client::new();
+ wait_for_source_status(&http, &api_url, ConnectorStatus::Running).await;
+
+ drop_replication_slot_retrying(&pool, DEFAULT_SLOT).await;
+ sqlx::query("SELECT pg_create_logical_replication_slot($1, 'pgoutput')")
+ .bind(DEFAULT_SLOT)
+ .execute(&pool)
+ .await
+ .expect("Failed to create a wrong-plugin slot");
+
+ // Assert on the restart call's own HTTP status, not last_error - the
+ // still-running old connector keeps polling in the background and
+ // could race a transient poll failure into last_error first.
+ let restart_resp = http
+ .post(format!("{api_url}/sources/{SOURCE_KEY}/restart"))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to call restart endpoint");
+ assert!(
+ !restart_resp.status().is_success(),
+ "Restart with a mismatched slot should not report success, got {}",
+ restart_resp.status()
+ );
+
+ // Operator fix: drop the bad slot, restart so the connector recreates it.
+ sqlx::query("SELECT pg_drop_replication_slot($1)")
+ .bind(DEFAULT_SLOT)
+ .execute(&pool)
+ .await
+ .expect("Failed to drop the bad slot");
+
+ let restart_resp = http
+ .post(format!("{api_url}/sources/{SOURCE_KEY}/restart"))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to call restart endpoint");
+ assert!(
+ restart_resp.status().is_success(),
+ "Restart after fixing the slot should succeed, got {}",
+ restart_resp.status()
+ );
+ wait_for_source_status(&http, &api_url, ConnectorStatus::Running).await;
+
+ let [before] = create_test_messages(1).try_into().unwrap();
+ fixture
+ .insert_row(
+ &pool,
+ before.id as i32,
+ &before.name,
+ before.count as i32,
+ before.amount,
+ before.active,
+ before.timestamp,
+ )
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(
+ received.len(),
+ 1,
+ "Expected CDC to resume capturing changes after the slot was fixed"
+ );
+
+ // Scenario 2: invalid plugin_config fields must be rejected at restart,
+ // one field at a time, until the config is fully valid again. Every
+ // pushed version is deleted again at the end (see
+ // delete_source_config_version) so nothing outlives this test on disk.
+ let baseline_config = get_active_source_config(&http, &api_url).await;
+ let mut pushed_versions = Vec::new();
+
+ let mut bad_ops = baseline_config.clone();
+ bad_ops["plugin_config"]["capture_operations"] =
serde_json::json!(["INSRT"]);
+ let version = push_source_config(&http, &api_url, bad_ops).await;
+ pushed_versions.push(version);
+ activate_source_config(&http, &api_url, version).await;
+ restart_source_expect(
+ &http,
+ &api_url,
+ false,
+ "Restart with an invalid capture_operations entry should not report
success",
+ )
+ .await;
+
+ let mut bad_format = baseline_config.clone();
+ bad_format["plugin_config"]["payload_column"] = serde_json::json!("name");
+ bad_format["plugin_config"]["payload_format"] = serde_json::json!("btea");
+ let version = push_source_config(&http, &api_url, bad_format).await;
+ pushed_versions.push(version);
+ activate_source_config(&http, &api_url, version).await;
+ restart_source_expect(
+ &http,
+ &api_url,
+ false,
+ "Restart with an invalid payload_format should not report success",
+ )
+ .await;
+
+ let version = push_source_config(&http, &api_url, baseline_config).await;
+ pushed_versions.push(version);
+ activate_source_config(&http, &api_url, version).await;
+ restart_source_expect(
+ &http,
+ &api_url,
+ true,
+ "Restart with a valid config should succeed",
+ )
+ .await;
+ wait_for_source_status(&http, &api_url, ConnectorStatus::Running).await;
+
+ let reconfigured_id = before.id as i32 + 1;
+ fixture
+ .insert_row(
+ &pool,
+ reconfigured_id,
+ "reconfigured_row",
+ 1,
+ 1.0,
+ true,
+ before.timestamp,
+ )
+ .await;
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(
+ received.len(),
+ 1,
+ "Expected CDC to resume capturing changes once the config was fixed"
+ );
+
+ // Deleting the still-active version last falls back to version 0 (the
+ // original config.toml), restoring the pre-test state exactly.
+ for version in pushed_versions {
+ delete_source_config_version(&http, &api_url, version).await;
+ }
+
+ harness
+ .server_mut()
+ .stop_dependents()
+ .expect("Failed to stop connectors");
+
+ // The replication slot retains WAL for changes made while nothing is
+ // consuming - this row must still arrive once the connector restarts.
+ let after_id = reconfigured_id + 1;
+ fixture
+ .insert_row(
+ &pool,
+ after_id,
+ "written_while_down",
+ 1,
+ 1.0,
+ true,
+ before.timestamp,
+ )
+ .await;
+
+ harness
+ .server_mut()
+ .start_dependents()
+ .await
+ .expect("Failed to restart connectors");
+ let api_url = harness
+ .connectors_runtime()
+ .expect("connector runtime should be available")
+ .http_url();
+ wait_for_source_status(&http, &api_url, ConnectorStatus::Running).await;
+
+ let received = poll_cdc_records(&client, &stream_id, &topic_id,
&consumer_id, 1).await;
+ assert_eq!(
+ received.len(),
+ 1,
+ "Expected the change written while the connector was down to arrive
after restart"
+ );
+ assert_eq!(received[0].operation_type, "INSERT");
+ assert_eq!(received[0].data["id"], serde_json::json!(after_id));
+ assert_eq!(
+ received[0].data["name"],
+ serde_json::json!("written_while_down")
+ );
+
+ pool.close().await;
+}
diff --git a/core/integration/tests/connectors/postgres/source_cdc_restart.toml
b/core/integration/tests/connectors/postgres/source_cdc_restart.toml
new file mode 100644
index 000000000..0cf9403ba
--- /dev/null
+++ b/core/integration/tests/connectors/postgres/source_cdc_restart.toml
@@ -0,0 +1,26 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Dedicated scratch config_dir (see cdc_restart_connectors/): this test posts
+# new plugin_config versions at runtime via the local config provider, which
+# globs every *.toml under config_dir on startup. Pointing that at the shared
+# ../connectors/sources/postgres_source directory (used by other postgres
+# source tests) would let a stray version leak into whichever of those tests
+# happens to boot its own runtime while this one is mid-run.
+[connectors]
+config_type = "local"
+config_dir = "tests/connectors/postgres/cdc_restart_connectors"