hubcio commented on code in PR #3957:
URL: https://github.com/apache/iggy/pull/3957#discussion_r3996880954


##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -695,7 +1015,9 @@ impl PostgresSource {
             ));
         }
 
-        if let Some(processed_col) = &self.config.processed_column {
+        if !self.config.delete_after_read.unwrap_or(false)

Review Comment:
   critical: with `delete_after_read=true` and `processed_column` together, 
previously excluded processed rows are now emitted and deleted. keep the 
processed-row selection filter while preserving delete precedence.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -230,38 +297,77 @@ impl Source for PostgresSource {
             }
         };
 
-        let state = self.state.lock().await;
+        let processed_rows = self.state.lock().await.processed_rows;
         if self.verbose {
             info!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         } else {
             debug!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         }
 
-        let schema = match self.payload_format() {
-            PayloadFormat::Bytea => Schema::Raw,
-            PayloadFormat::Text => Schema::Text,
-            PayloadFormat::JsonDirect | PayloadFormat::Json => Schema::Json,
-        };
-
-        let persisted_state = self.serialize_state(&state);
+        let persisted_state = polled
+            .pending
+            .as_ref()
+            .map(|pending| {
+                self.serialize_state(&pending.state).ok_or_else(|| {

Review Comment:
   warning: a restart after checkpoint save loses pending cleanup, leaving 
unchanged rows behind the saved cursor. persist cleanup intent that identifies 
original rows, replay and retire it safely, and retain compatibility with older 
checkpoints.



##########
core/connectors/runtime/src/source.rs:
##########
@@ -604,6 +604,8 @@ pub(crate) async fn source_forwarding_loop(
                 context.metrics.inc_errors_with_labels(&labels.counter);
                 context.sources.set_error(&plugin_key, &error_msg).await;
             }
+        } else if batch_result == SourceBatchResult::Ack {

Review Comment:
   warning: empty or fully filtered batches can clear a delivery error without 
contacting iggy. require `sent_count > 0` before restoring `Running`, while 
retaining empty-batch ACK and checkpoint handling.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -417,43 +533,55 @@ impl PostgresSource {
             
(!self.config.tables.is_empty()).then_some(self.config.tables.as_slice());
         let batch_size = self.config.batch_size.unwrap_or(1000) as i32;
 
+        let wal_flush_lsn = with_retry(
+            || sqlx::query_scalar("SELECT 
pg_current_wal_flush_lsn()::text").fetch_one(pool),

Review Comment:
   warning: `pg_current_wal_flush_lsn()` fails during recovery, so builtin CDC 
cannot poll a hot standby. use `pg_last_wal_replay_lsn()` during recovery and 
the flush LSN otherwise, keeping this read before the peek.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -669,6 +956,39 @@ impl PostgresSource {
         self.config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES)
     }
 
+    fn should_process_rows(&self) -> bool {
+        self.config.delete_after_read.unwrap_or(false) || 
self.config.processed_column.is_some()
+    }
+
+    fn processing_boundary(&self, max_offset: Option<String>) -> 
Option<String> {

Review Comment:
   warning: ordered custom queries using `$offset` keep the old cursor after 
ACK, repeating the first page indefinitely. advance the acknowledged cursor for 
ascending, unique tracking values and keep cleanup boundaries separate.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -733,18 +1057,20 @@ impl PostgresSource {
         batch_size: u32,
     ) -> String {
         let offset_value = last_offset
-            .clone()
-            .or_else(|| self.config.initial_offset.clone())
+            .as_deref()
+            .or(self.config.initial_offset.as_deref())
             .unwrap_or_default();
+        let formatted_offset = format_offset_value(offset_value);
 
         let now = Utc::now();
 
         query
             .replace("$table", table)
-            .replace("$offset", &offset_value)
+            .replace("'$offset'", &formatted_offset)
+            .replace("$offset", &formatted_offset)

Review Comment:
   critical: quoted `$offset` templates can expand a restored offset twice, 
turning data into SQL and selecting rows outside the filter for cleanup. 
substitute original tokens once, keeping replacement values opaque and 
preserving SQL literal context.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -284,12 +390,18 @@ impl PostgresSource {
     async fn connect(&mut self) -> Result<(), Error> {
         let max_connections = self.config.max_connections.unwrap_or(10);
         let redacted = 
redact_connection_string(self.config.connection_string.expose_secret());
+        let connect_options =
+            
PgConnectOptions::from_str(self.config.connection_string.expose_secret())
+                .map_err(|e| {
+                    Error::InitError(format!("Invalid PostgreSQL connection 
string: {e}"))
+                })?
+                .options([("statement_timeout", 
STATEMENT_TIMEOUT.as_millis())]);

Review Comment:
   warning: the pool-wide `statement_timeout` also cancels valid slow polling 
queries and CDC peeks, so they never make progress. apply the short timeout 
only to ACK statements and restore connection settings safely after 
cancellation.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -707,6 +1029,8 @@ impl PostgresSource {
         };
 
         let order_clause = format!(" ORDER BY {quoted_tracking} ASC");
+        // TODO: Add a unique tiebreaker to pagination. Advancing only the 
tracking-column

Review Comment:
   critical: this existing scalar cursor skips unsent rows tied at the page 
boundary, even with a unique cleanup key. use a stable unique composite cursor 
with legacy checkpoint handling, or reject non-unique tracking columns.



##########
core/connectors/runtime/src/stream.rs:
##########
@@ -24,6 +24,11 @@ use crate::error::RuntimeError;
 
 const TOKEN_FILE_PREFIX: &str = "file:";
 
+fn append_query_parameters(connection_string: &str, address: &str, parameters: 
&str) -> String {

Review Comment:
   nit: the reason for checking `address` rather than the full connection 
string is undocumented. explain that credentials may contain `?` and that 
`address` must match the connection string's suffix.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -230,38 +297,77 @@ impl Source for PostgresSource {
             }
         };
 
-        let state = self.state.lock().await;
+        let processed_rows = self.state.lock().await.processed_rows;
         if self.verbose {
             info!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         } else {
             debug!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         }
 
-        let schema = match self.payload_format() {
-            PayloadFormat::Bytea => Schema::Raw,
-            PayloadFormat::Text => Schema::Text,
-            PayloadFormat::JsonDirect | PayloadFormat::Json => Schema::Json,
-        };
-
-        let persisted_state = self.serialize_state(&state);
+        let persisted_state = polled
+            .pending
+            .as_ref()
+            .map(|pending| {
+                self.serialize_state(&pending.state).ok_or_else(|| {
+                    Error::Serialization("failed to serialize PostgreSQL 
source state".to_string())
+                })
+            })
+            .transpose()?;
+        *self.pending_batch.lock().await = polled.pending;
 
         Ok(ProducedMessages {
             schema,
-            messages,
+            messages: polled.messages,
             state: persisted_state,
         })
     }
 
+    async fn on_batch_result(&self, result: SourceBatchResult) -> Result<(), 
Error> {
+        if result == SourceBatchResult::Nack {
+            let mut pending = self.pending_batch.lock().await;
+            if pending
+                .as_ref()
+                .is_some_and(|pending| !pending.acknowledged)
+            {
+                pending.take();
+            }
+            return Ok(());
+        }
+
+        let Some(pending) = self.pending_batch.lock().await.as_ref().cloned() 
else {
+            return Ok(());
+        };
+
+        let PendingBatch {
+            state, operations, ..
+        } = pending;
+        let deadline = tokio::time::Instant::now() + ACK_BATCH_TIMEOUT;
+        let failed_operations = self.apply_pending_operations(operations, 
deadline).await?;

Review Comment:
   critical: existing buffered ACKs on a single node can precede disk flush, so 
a crash after source cleanup can lose messages. require durable destination 
storage before checkpoint and source ACK, with fsync for power-loss safety.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -230,38 +297,77 @@ impl Source for PostgresSource {
             }
         };
 
-        let state = self.state.lock().await;
+        let processed_rows = self.state.lock().await.processed_rows;
         if self.verbose {
             info!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         } else {
             debug!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         }
 
-        let schema = match self.payload_format() {
-            PayloadFormat::Bytea => Schema::Raw,
-            PayloadFormat::Text => Schema::Text,
-            PayloadFormat::JsonDirect | PayloadFormat::Json => Schema::Json,
-        };
-
-        let persisted_state = self.serialize_state(&state);
+        let persisted_state = polled
+            .pending
+            .as_ref()
+            .map(|pending| {
+                self.serialize_state(&pending.state).ok_or_else(|| {
+                    Error::Serialization("failed to serialize PostgreSQL 
source state".to_string())
+                })
+            })
+            .transpose()?;
+        *self.pending_batch.lock().await = polled.pending;
 
         Ok(ProducedMessages {
             schema,
-            messages,
+            messages: polled.messages,
             state: persisted_state,
         })
     }
 
+    async fn on_batch_result(&self, result: SourceBatchResult) -> Result<(), 
Error> {
+        if result == SourceBatchResult::Nack {
+            let mut pending = self.pending_batch.lock().await;
+            if pending
+                .as_ref()
+                .is_some_and(|pending| !pending.acknowledged)
+            {
+                pending.take();
+            }
+            return Ok(());
+        }
+
+        let Some(pending) = self.pending_batch.lock().await.as_ref().cloned() 
else {
+            return Ok(());
+        };
+
+        let PendingBatch {
+            state, operations, ..
+        } = pending;
+        let deadline = tokio::time::Instant::now() + ACK_BATCH_TIMEOUT;

Review Comment:
   warning: the existing SDK deadline can expire after ACK is claimed but 
before cleanup finishes, permanently stopping polling. track ACK receipt 
separately from bounded hook completion, while keeping one batch in flight.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -995,12 +1313,11 @@ fn extract_column_value(
                 .unwrap_or(serde_json::Value::Null))
         }
         "NUMERIC" => {
-            let value: Option<String> = row
+            let value: Option<BigDecimal> = row

Review Comment:
   warning: valid `NUMERIC` values such as `NaN` still fail `BigDecimal` 
decoding and block polling. handle supported special values as strings, 
preserving `NULL`, real decode errors, and quoted tracking literals.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -733,18 +1057,20 @@ impl PostgresSource {
         batch_size: u32,
     ) -> String {
         let offset_value = last_offset
-            .clone()
-            .or_else(|| self.config.initial_offset.clone())
+            .as_deref()
+            .or(self.config.initial_offset.as_deref())
             .unwrap_or_default();
+        let formatted_offset = format_offset_value(offset_value);
 
         let now = Utc::now();
 
         query
             .replace("$table", table)
-            .replace("$offset", &offset_value)
+            .replace("'$offset'", &formatted_offset)
+            .replace("$offset", &formatted_offset)
             .replace("$limit", &batch_size.to_string())

Review Comment:
   warning: the existing `$limit` and `$now` replacements rewrite those tokens 
inside inserted offsets, changing predicates. scan only the original template 
and leave replacement values untouched.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -550,52 +680,207 @@ impl PostgresSource {
 
                 messages.push(processed.message);
                 total_processed += 1;
+                table_processed += 1;
             }
 
-            // Database I/O without holding the lock
-            if !processed_ids.is_empty() {
-                self.mark_or_delete_processed_rows(pool, table, pk_column, 
&processed_ids)
-                    .await?;
+            let tracking_boundary = self.processing_boundary(max_offset);
+
+            if self.should_process_rows() && !processed_ids.is_empty() {
+                operations.push(PendingOperation::ProcessRows {
+                    table: table.clone(),
+                    ids: processed_ids,
+                    tracking_boundary: tracking_boundary.clone(),
+                });
             }
 
-            // Collect offset update for later
-            if let Some(offset) = max_offset {
-                state_updates.push((table.clone(), offset));
+            if let Some(offset) = tracking_boundary {
+                candidate_state
+                    .tracking_offsets
+                    .insert(table.clone(), offset);
             }
 
             if self.verbose {
-                info!("Fetched {} rows from table '{table}'", messages.len());
+                info!("Fetched {table_processed} rows from table '{table}'");
             } else {
-                debug!("Fetched {} rows from table '{table}'", messages.len());
+                debug!("Fetched {table_processed} rows from table '{table}'");
             }
         }
 
-        // Apply all state updates with a single lock acquisition
-        {
-            let mut state = self.state.lock().await;
-            state.processed_rows += total_processed;
-            for (table, offset) in state_updates {
-                state.tracking_offsets.insert(table, offset);
+        let pending = if total_processed > 0 {
+            candidate_state.processed_rows += total_processed;
+            candidate_state.last_poll_time = Utc::now();
+            Some(PendingBatch {
+                state: candidate_state,
+                operations,
+                acknowledged: false,
+            })
+        } else {
+            None
+        };
+
+        Ok(PolledBatch { messages, pending })
+    }
+
+    async fn advance_replication_slot(&self, lsn: &str) -> Result<(), Error> {
+        let slot_name = self.replication_slot();
+        let pool = self.get_pool()?;
+        with_retry(
+            || async {
+                let result = sqlx::query("SELECT 
pg_replication_slot_advance($1, $2::pg_lsn)")
+                    .bind(slot_name)
+                    .bind(lsn)
+                    .execute(pool)
+                    .await;
+
+                if let Err(error) = result {
+                    match replication_slot_reached_target(pool, slot_name, 
lsn).await {
+                        Ok(true) => {
+                            warn!(
+                                "PostgreSQL source connector ID: {} confirmed 
replication slot \
+                                 '{slot_name}' is already at or beyond {lsn} 
after advance failed: \
+                                 {error}",
+                                self.id
+                            );
+                            return Ok(());
+                        }
+                        Ok(false) => {}
+                        Err(read_error) => warn!(
+                            "PostgreSQL source connector ID: {} failed to read 
replication slot \
+                             '{slot_name}' after advance failed: {read_error}",
+                            self.id
+                        ),
+                    }
+                    return Err(error);
+                }
+
+                Ok(())
+            },
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| {
+            Error::Connection(format!(
+                "failed to advance replication slot '{slot_name}' to {lsn}: 
{e}"
+            ))
+        })?;
+        Ok(())
+    }
+
+    async fn apply_pending_operations(
+        &self,
+        operations: Vec<PendingOperation>,
+        deadline: tokio::time::Instant,
+    ) -> Result<Vec<PendingOperation>, Error> {
+        let mut failed_operations = Vec::new();
+        for operation in operations {
+            if !self.apply_pending_operation(&operation, deadline).await? {
+                failed_operations.push(operation);
+            }
+        }
+        Ok(failed_operations)
+    }
+
+    async fn apply_pending_operation(
+        &self,
+        operation: &PendingOperation,
+        deadline: tokio::time::Instant,
+    ) -> Result<bool, Error> {
+        let (operation_name, advances_slot) = match operation {
+            PendingOperation::ProcessRows { .. } => ("process rows", false),
+            PendingOperation::AdvanceReplicationSlot { .. } => ("advance 
replication slot", true),
+        };
+
+        let result =
+            tokio::time::timeout_at(deadline, 
self.execute_pending_operation(operation)).await;
+
+        match result {
+            Ok(Ok(())) => {
+                if advances_slot {
+                    self.consecutive_advance_failures
+                        .store(0, Ordering::Relaxed);
+                }
+                Ok(true)
+            }
+            Ok(Err(error)) => {
+                self.record_pending_operation_failure(operation_name, 
advances_slot, &error)?;
+                Ok(false)
+            }
+            Err(_) => {
+                self.record_pending_operation_failure(
+                    operation_name,
+                    advances_slot,
+                    "operation exceeded the shared 10s ACK batch budget",
+                )?;
+                Ok(false)
+            }
+        }
+    }
+
+    async fn execute_pending_operation(&self, operation: &PendingOperation) -> 
Result<(), Error> {
+        match operation {
+            PendingOperation::ProcessRows {
+                table,
+                ids,
+                tracking_boundary,
+            } => {
+                let pool = self.get_pool()?;
+                self.mark_or_delete_processed_rows(pool, table, ids, 
tracking_boundary.as_deref())
+                    .await
+            }
+            PendingOperation::AdvanceReplicationSlot { lsn } => {
+                self.advance_replication_slot(lsn).await
             }
-            state.last_poll_time = Utc::now();
         }
+    }
 
-        Ok(messages)
+    fn record_pending_operation_failure(
+        &self,
+        operation_name: &str,
+        advances_slot: bool,
+        error: impl std::fmt::Display,
+    ) -> Result<(), Error> {
+        if advances_slot {
+            let consecutive_failures = self
+                .consecutive_advance_failures
+                .fetch_add(1, Ordering::Relaxed)
+                .saturating_add(1);
+            error!(
+                "Failed to {operation_name} for PostgreSQL source connector 
ID: {}. \
+                 Consecutive replication slot advance failures: 
{consecutive_failures}. {error}",
+                self.id
+            );
+            if consecutive_failures >= MAX_CONSECUTIVE_ADVANCE_FAILURES {
+                return Err(Error::Connection(format!(
+                    "stopping PostgreSQL source connector ID {} after 
{consecutive_failures} \
+                     consecutive replication slot advance failures",
+                    self.id
+                )));
+            }
+        } else {

Review Comment:
   warning: permanent cleanup failures already stall the source while it 
reports `Running`; staged ACK retries remain unbounded. cap `ProcessRows` 
failures and return `Err` from `on_batch_result`, preserving pending work.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -550,52 +680,207 @@ impl PostgresSource {
 
                 messages.push(processed.message);
                 total_processed += 1;
+                table_processed += 1;
             }
 
-            // Database I/O without holding the lock
-            if !processed_ids.is_empty() {
-                self.mark_or_delete_processed_rows(pool, table, pk_column, 
&processed_ids)
-                    .await?;
+            let tracking_boundary = self.processing_boundary(max_offset);
+
+            if self.should_process_rows() && !processed_ids.is_empty() {
+                operations.push(PendingOperation::ProcessRows {
+                    table: table.clone(),
+                    ids: processed_ids,
+                    tracking_boundary: tracking_boundary.clone(),
+                });
             }
 
-            // Collect offset update for later
-            if let Some(offset) = max_offset {
-                state_updates.push((table.clone(), offset));
+            if let Some(offset) = tracking_boundary {
+                candidate_state
+                    .tracking_offsets
+                    .insert(table.clone(), offset);
             }
 
             if self.verbose {
-                info!("Fetched {} rows from table '{table}'", messages.len());
+                info!("Fetched {table_processed} rows from table '{table}'");
             } else {
-                debug!("Fetched {} rows from table '{table}'", messages.len());
+                debug!("Fetched {table_processed} rows from table '{table}'");
             }
         }
 
-        // Apply all state updates with a single lock acquisition
-        {
-            let mut state = self.state.lock().await;
-            state.processed_rows += total_processed;
-            for (table, offset) in state_updates {
-                state.tracking_offsets.insert(table, offset);
+        let pending = if total_processed > 0 {
+            candidate_state.processed_rows += total_processed;
+            candidate_state.last_poll_time = Utc::now();
+            Some(PendingBatch {
+                state: candidate_state,
+                operations,
+                acknowledged: false,
+            })
+        } else {
+            None
+        };
+
+        Ok(PolledBatch { messages, pending })
+    }
+
+    async fn advance_replication_slot(&self, lsn: &str) -> Result<(), Error> {
+        let slot_name = self.replication_slot();
+        let pool = self.get_pool()?;
+        with_retry(
+            || async {
+                let result = sqlx::query("SELECT 
pg_replication_slot_advance($1, $2::pg_lsn)")
+                    .bind(slot_name)
+                    .bind(lsn)
+                    .execute(pool)
+                    .await;
+
+                if let Err(error) = result {
+                    match replication_slot_reached_target(pool, slot_name, 
lsn).await {
+                        Ok(true) => {
+                            warn!(
+                                "PostgreSQL source connector ID: {} confirmed 
replication slot \
+                                 '{slot_name}' is already at or beyond {lsn} 
after advance failed: \
+                                 {error}",
+                                self.id
+                            );
+                            return Ok(());
+                        }
+                        Ok(false) => {}
+                        Err(read_error) => warn!(
+                            "PostgreSQL source connector ID: {} failed to read 
replication slot \
+                             '{slot_name}' after advance failed: {read_error}",
+                            self.id
+                        ),
+                    }
+                    return Err(error);
+                }
+
+                Ok(())
+            },
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| {
+            Error::Connection(format!(
+                "failed to advance replication slot '{slot_name}' to {lsn}: 
{e}"
+            ))
+        })?;
+        Ok(())
+    }
+
+    async fn apply_pending_operations(
+        &self,
+        operations: Vec<PendingOperation>,
+        deadline: tokio::time::Instant,
+    ) -> Result<Vec<PendingOperation>, Error> {
+        let mut failed_operations = Vec::new();
+        for operation in operations {
+            if !self.apply_pending_operation(&operation, deadline).await? {
+                failed_operations.push(operation);
+            }
+        }
+        Ok(failed_operations)
+    }
+
+    async fn apply_pending_operation(
+        &self,
+        operation: &PendingOperation,
+        deadline: tokio::time::Instant,
+    ) -> Result<bool, Error> {
+        let (operation_name, advances_slot) = match operation {
+            PendingOperation::ProcessRows { .. } => ("process rows", false),
+            PendingOperation::AdvanceReplicationSlot { .. } => ("advance 
replication slot", true),
+        };
+
+        let result =
+            tokio::time::timeout_at(deadline, 
self.execute_pending_operation(operation)).await;
+
+        match result {
+            Ok(Ok(())) => {
+                if advances_slot {
+                    self.consecutive_advance_failures
+                        .store(0, Ordering::Relaxed);
+                }
+                Ok(true)
+            }
+            Ok(Err(error)) => {
+                self.record_pending_operation_failure(operation_name, 
advances_slot, &error)?;
+                Ok(false)
+            }
+            Err(_) => {
+                self.record_pending_operation_failure(
+                    operation_name,
+                    advances_slot,
+                    "operation exceeded the shared 10s ACK batch budget",
+                )?;
+                Ok(false)
+            }
+        }
+    }
+
+    async fn execute_pending_operation(&self, operation: &PendingOperation) -> 
Result<(), Error> {
+        match operation {
+            PendingOperation::ProcessRows {
+                table,
+                ids,
+                tracking_boundary,
+            } => {
+                let pool = self.get_pool()?;
+                self.mark_or_delete_processed_rows(pool, table, ids, 
tracking_boundary.as_deref())
+                    .await
+            }
+            PendingOperation::AdvanceReplicationSlot { lsn } => {
+                self.advance_replication_slot(lsn).await
             }
-            state.last_poll_time = Utc::now();
         }
+    }
 
-        Ok(messages)
+    fn record_pending_operation_failure(
+        &self,
+        operation_name: &str,
+        advances_slot: bool,
+        error: impl std::fmt::Display,
+    ) -> Result<(), Error> {
+        if advances_slot {
+            let consecutive_failures = self
+                .consecutive_advance_failures
+                .fetch_add(1, Ordering::Relaxed)
+                .saturating_add(1);
+            error!(
+                "Failed to {operation_name} for PostgreSQL source connector 
ID: {}. \
+                 Consecutive replication slot advance failures: 
{consecutive_failures}. {error}",
+                self.id
+            );
+            if consecutive_failures >= MAX_CONSECUTIVE_ADVANCE_FAILURES {
+                return Err(Error::Connection(format!(
+                    "stopping PostgreSQL source connector ID {} after 
{consecutive_failures} \
+                     consecutive replication slot advance failures",
+                    self.id
+                )));
+            }
+        } else {
+            error!(
+                "Failed to {operation_name} for PostgreSQL source connector 
ID: {}. {error}",
+                self.id
+            );
+        }
+        Ok(())
     }
 
     async fn mark_or_delete_processed_rows(
         &self,
         pool: &Pool<Postgres>,
         table: &str,
-        pk_column: &str,
         ids: &[String],
+        tracking_boundary: Option<&str>,
     ) -> Result<(), Error> {
         if ids.is_empty() {
             return Ok(());
         }
 
         let quoted_table = quote_qualified_identifier(table)?;
-        let quoted_pk = quote_identifier(pk_column)?;
+        let quoted_pk = quote_identifier(self.primary_key_column())?;

Review Comment:
   critical: this existing fallback can delete or mark rows that were never 
emitted when tracking values are shared. require the resolved cleanup key to 
identify rows uniquely, including when its column is configured explicitly.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -230,38 +297,77 @@ impl Source for PostgresSource {
             }
         };
 
-        let state = self.state.lock().await;
+        let processed_rows = self.state.lock().await.processed_rows;
         if self.verbose {
             info!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         } else {
             debug!(
                 "PostgreSQL source connector ID: {} produced {} messages. 
Total processed: {}",
                 self.id,
-                messages.len(),
-                state.processed_rows
+                polled.messages.len(),
+                processed_rows
             );
         }
 
-        let schema = match self.payload_format() {
-            PayloadFormat::Bytea => Schema::Raw,
-            PayloadFormat::Text => Schema::Text,
-            PayloadFormat::JsonDirect | PayloadFormat::Json => Schema::Json,
-        };
-
-        let persisted_state = self.serialize_state(&state);
+        let persisted_state = polled

Review Comment:
   warning: idle CDC polls still save unchanged state, repeatedly writing and 
syncing the checkpoint file. return no state when its contents are unchanged, 
while preserving slot advancement and empty-batch ACK handling.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -1460,6 +1777,59 @@ fn format_offset_value(value: &str) -> String {
     }
 }
 
+fn build_tracking_condition(
+    tracking_column: &str,
+    tracking_boundary: Option<&str>,
+) -> Result<String, Error> {
+    let Some(boundary) = tracking_boundary else {
+        return Ok(String::new());
+    };
+    let quoted_tracking = quote_identifier(tracking_column)?;
+    Ok(format!(
+        " AND ({quoted_tracking} <= {} OR {quoted_tracking} IS NULL)",
+        format_offset_value(boundary)
+    ))
+}
+
+fn replication_slot_target_lsn(last_change_lsn: Option<String>, wal_flush_lsn: 
String) -> String {
+    last_change_lsn.unwrap_or(wal_flush_lsn)
+}
+
+async fn replication_slot_reached_target(
+    pool: &Pool<Postgres>,
+    slot_name: &str,
+    target_lsn: &str,
+) -> Result<bool, sqlx::Error> {
+    Ok(sqlx::query_scalar::<_, bool>(
+        "SELECT COALESCE(confirmed_flush_lsn >= $2::pg_lsn, FALSE) \
+         FROM pg_replication_slots WHERE slot_name = $1",
+    )
+    .bind(slot_name)
+    .bind(target_lsn)
+    .fetch_optional(pool)
+    .await?
+    .unwrap_or(false))
+}
+
+fn extract_tracking_value(
+    row: &sqlx::postgres::PgRow,
+    column_index: usize,
+    value: &serde_json::Value,
+) -> Result<Option<String>, Error> {
+    if row.columns()[column_index].type_info().name() == "NUMERIC" {

Review Comment:
   simplification: `extract_tracking_value` decodes `NUMERIC` again after 
`extract_column_value` already produced the exact string. reuse that value and 
remove the duplicate branch, row/index parameters, and `Result` wrapper.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -837,19 +1163,11 @@ impl PostgresSource {
             data.insert(column_name.clone(), value.clone());

Review Comment:
   simplification: row conversion still clones the owned column name and JSON 
value before extracting tracking fields. extract tracking and key values first, 
then move both into `data.insert(column_name, value)`.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -1979,6 +2436,24 @@ mod tests {
         assert!(validate_capture_operations(None).is_ok());
     }
 
+    #[test]
+    fn given_postgres_10_should_fail_cdc_version_validation() {
+        let error = validate_cdc_server_version_num(100_000).unwrap_err();
+
+        assert!(matches!(error, Error::InitError(_)));
+    }
+
+    #[test]
+    fn given_postgres_11_or_newer_should_pass_cdc_version_validation() {
+        assert!(validate_cdc_server_version_num(110_000).is_ok());
+        assert!(validate_cdc_server_version_num(170_000).is_ok());
+    }
+
+    #[test]
+    fn statement_timeout_should_expire_before_ack_backstop() {

Review Comment:
   simplification: this test only compares compile-time timeout constants. 
replace it with `const _: () = assert!(STATEMENT_TIMEOUT.as_nanos() < 
ACK_BATCH_TIMEOUT.as_nanos());` beside the constants.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -1460,6 +1777,59 @@ fn format_offset_value(value: &str) -> String {
     }
 }
 
+fn build_tracking_condition(
+    tracking_column: &str,
+    tracking_boundary: Option<&str>,
+) -> Result<String, Error> {
+    let Some(boundary) = tracking_boundary else {
+        return Ok(String::new());
+    };
+    let quoted_tracking = quote_identifier(tracking_column)?;
+    Ok(format!(
+        " AND ({quoted_tracking} <= {} OR {quoted_tracking} IS NULL)",
+        format_offset_value(boundary)
+    ))
+}
+
+fn replication_slot_target_lsn(last_change_lsn: Option<String>, wal_flush_lsn: 
String) -> String {

Review Comment:
   simplification: this helper only wraps `Option::unwrap_or`, and its tests 
only check that expression. inline `last_lsn.unwrap_or(wal_flush_lsn)` at the 
caller and remove the helper-only tests.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -417,43 +533,55 @@ impl PostgresSource {
             
(!self.config.tables.is_empty()).then_some(self.config.tables.as_slice());
         let batch_size = self.config.batch_size.unwrap_or(1000) as i32;
 
+        let wal_flush_lsn = with_retry(
+            || sqlx::query_scalar("SELECT 
pg_current_wal_flush_lsn()::text").fetch_one(pool),
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| Error::Connection(format!("failed to read current WAL 
flush LSN: {e}")))?;
+
         // 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)")
+        let rows = with_retry(
+            || {
+                sqlx::query(
+                    "SELECT lsn::text AS lsn, data FROM 
pg_logical_slot_peek_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
-                })?;
+            },
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| Error::Connection(format!("failed to fetch CDC changes: 
{e}")))?;
 
         let mut messages = Vec::new();
+        let mut last_lsn = None;
 
         for row in rows {
-            let data: String = match row.try_get("data") {
-                Ok(data) => data,
-                Err(e) => {
-                    error!("Skipping CDC row with unreadable data column: 
{e}");
-                    continue;
-                }
-            };
+            let lsn: String = row.try_get("lsn").map_err(|e| {

Review Comment:
   simplification: CDC decodes an owned LSN for every row but only keeps the 
last. decode `rows.last()` once, preserving the pre-peek fallback, all payload 
checks, and progress through filtered rows.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -550,52 +680,207 @@ impl PostgresSource {
 
                 messages.push(processed.message);
                 total_processed += 1;
+                table_processed += 1;
             }
 
-            // Database I/O without holding the lock
-            if !processed_ids.is_empty() {
-                self.mark_or_delete_processed_rows(pool, table, pk_column, 
&processed_ids)
-                    .await?;
+            let tracking_boundary = self.processing_boundary(max_offset);
+
+            if self.should_process_rows() && !processed_ids.is_empty() {
+                operations.push(PendingOperation::ProcessRows {
+                    table: table.clone(),
+                    ids: processed_ids,
+                    tracking_boundary: tracking_boundary.clone(),
+                });
             }
 
-            // Collect offset update for later
-            if let Some(offset) = max_offset {
-                state_updates.push((table.clone(), offset));
+            if let Some(offset) = tracking_boundary {
+                candidate_state
+                    .tracking_offsets
+                    .insert(table.clone(), offset);
             }
 
             if self.verbose {
-                info!("Fetched {} rows from table '{table}'", messages.len());
+                info!("Fetched {table_processed} rows from table '{table}'");
             } else {
-                debug!("Fetched {} rows from table '{table}'", messages.len());
+                debug!("Fetched {table_processed} rows from table '{table}'");
             }
         }
 
-        // Apply all state updates with a single lock acquisition
-        {
-            let mut state = self.state.lock().await;
-            state.processed_rows += total_processed;
-            for (table, offset) in state_updates {
-                state.tracking_offsets.insert(table, offset);
+        let pending = if total_processed > 0 {
+            candidate_state.processed_rows += total_processed;
+            candidate_state.last_poll_time = Utc::now();
+            Some(PendingBatch {
+                state: candidate_state,
+                operations,
+                acknowledged: false,
+            })
+        } else {
+            None
+        };
+
+        Ok(PolledBatch { messages, pending })
+    }
+
+    async fn advance_replication_slot(&self, lsn: &str) -> Result<(), Error> {
+        let slot_name = self.replication_slot();
+        let pool = self.get_pool()?;
+        with_retry(
+            || async {
+                let result = sqlx::query("SELECT 
pg_replication_slot_advance($1, $2::pg_lsn)")
+                    .bind(slot_name)
+                    .bind(lsn)
+                    .execute(pool)
+                    .await;
+
+                if let Err(error) = result {
+                    match replication_slot_reached_target(pool, slot_name, 
lsn).await {
+                        Ok(true) => {
+                            warn!(
+                                "PostgreSQL source connector ID: {} confirmed 
replication slot \
+                                 '{slot_name}' is already at or beyond {lsn} 
after advance failed: \
+                                 {error}",
+                                self.id
+                            );
+                            return Ok(());
+                        }
+                        Ok(false) => {}
+                        Err(read_error) => warn!(
+                            "PostgreSQL source connector ID: {} failed to read 
replication slot \
+                             '{slot_name}' after advance failed: {read_error}",
+                            self.id
+                        ),
+                    }
+                    return Err(error);
+                }
+
+                Ok(())
+            },
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| {
+            Error::Connection(format!(
+                "failed to advance replication slot '{slot_name}' to {lsn}: 
{e}"
+            ))
+        })?;
+        Ok(())
+    }
+
+    async fn apply_pending_operations(
+        &self,
+        operations: Vec<PendingOperation>,
+        deadline: tokio::time::Instant,
+    ) -> Result<Vec<PendingOperation>, Error> {
+        let mut failed_operations = Vec::new();
+        for operation in operations {
+            if !self.apply_pending_operation(&operation, deadline).await? {
+                failed_operations.push(operation);
+            }
+        }
+        Ok(failed_operations)
+    }
+
+    async fn apply_pending_operation(
+        &self,
+        operation: &PendingOperation,
+        deadline: tokio::time::Instant,
+    ) -> Result<bool, Error> {
+        let (operation_name, advances_slot) = match operation {
+            PendingOperation::ProcessRows { .. } => ("process rows", false),
+            PendingOperation::AdvanceReplicationSlot { .. } => ("advance 
replication slot", true),
+        };
+
+        let result =
+            tokio::time::timeout_at(deadline, 
self.execute_pending_operation(operation)).await;
+
+        match result {
+            Ok(Ok(())) => {
+                if advances_slot {
+                    self.consecutive_advance_failures
+                        .store(0, Ordering::Relaxed);
+                }
+                Ok(true)
+            }
+            Ok(Err(error)) => {
+                self.record_pending_operation_failure(operation_name, 
advances_slot, &error)?;
+                Ok(false)
+            }
+            Err(_) => {
+                self.record_pending_operation_failure(
+                    operation_name,
+                    advances_slot,
+                    "operation exceeded the shared 10s ACK batch budget",
+                )?;
+                Ok(false)
+            }
+        }
+    }
+
+    async fn execute_pending_operation(&self, operation: &PendingOperation) -> 
Result<(), Error> {
+        match operation {
+            PendingOperation::ProcessRows {
+                table,
+                ids,
+                tracking_boundary,
+            } => {
+                let pool = self.get_pool()?;
+                self.mark_or_delete_processed_rows(pool, table, ids, 
tracking_boundary.as_deref())
+                    .await
+            }
+            PendingOperation::AdvanceReplicationSlot { lsn } => {
+                self.advance_replication_slot(lsn).await
             }
-            state.last_poll_time = Utc::now();
         }
+    }
 
-        Ok(messages)
+    fn record_pending_operation_failure(

Review Comment:
   simplification: `operation_name` and `advances_slot` encode the same enum 
variant. pass `&PendingOperation` to the failure handler and match there, 
preserving the slot-only counter and success reset.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -550,52 +680,207 @@ impl PostgresSource {
 
                 messages.push(processed.message);
                 total_processed += 1;
+                table_processed += 1;
             }
 
-            // Database I/O without holding the lock
-            if !processed_ids.is_empty() {
-                self.mark_or_delete_processed_rows(pool, table, pk_column, 
&processed_ids)
-                    .await?;
+            let tracking_boundary = self.processing_boundary(max_offset);
+
+            if self.should_process_rows() && !processed_ids.is_empty() {
+                operations.push(PendingOperation::ProcessRows {
+                    table: table.clone(),
+                    ids: processed_ids,
+                    tracking_boundary: tracking_boundary.clone(),
+                });
             }
 
-            // Collect offset update for later
-            if let Some(offset) = max_offset {
-                state_updates.push((table.clone(), offset));
+            if let Some(offset) = tracking_boundary {
+                candidate_state
+                    .tracking_offsets
+                    .insert(table.clone(), offset);
             }
 
             if self.verbose {
-                info!("Fetched {} rows from table '{table}'", messages.len());
+                info!("Fetched {table_processed} rows from table '{table}'");
             } else {
-                debug!("Fetched {} rows from table '{table}'", messages.len());
+                debug!("Fetched {table_processed} rows from table '{table}'");
             }
         }
 
-        // Apply all state updates with a single lock acquisition
-        {
-            let mut state = self.state.lock().await;
-            state.processed_rows += total_processed;
-            for (table, offset) in state_updates {
-                state.tracking_offsets.insert(table, offset);
+        let pending = if total_processed > 0 {
+            candidate_state.processed_rows += total_processed;
+            candidate_state.last_poll_time = Utc::now();
+            Some(PendingBatch {
+                state: candidate_state,
+                operations,
+                acknowledged: false,
+            })
+        } else {
+            None
+        };
+
+        Ok(PolledBatch { messages, pending })
+    }
+
+    async fn advance_replication_slot(&self, lsn: &str) -> Result<(), Error> {
+        let slot_name = self.replication_slot();
+        let pool = self.get_pool()?;
+        with_retry(
+            || async {
+                let result = sqlx::query("SELECT 
pg_replication_slot_advance($1, $2::pg_lsn)")
+                    .bind(slot_name)
+                    .bind(lsn)
+                    .execute(pool)
+                    .await;
+
+                if let Err(error) = result {
+                    match replication_slot_reached_target(pool, slot_name, 
lsn).await {
+                        Ok(true) => {
+                            warn!(
+                                "PostgreSQL source connector ID: {} confirmed 
replication slot \
+                                 '{slot_name}' is already at or beyond {lsn} 
after advance failed: \
+                                 {error}",
+                                self.id
+                            );
+                            return Ok(());
+                        }
+                        Ok(false) => {}
+                        Err(read_error) => warn!(
+                            "PostgreSQL source connector ID: {} failed to read 
replication slot \
+                             '{slot_name}' after advance failed: {read_error}",
+                            self.id
+                        ),
+                    }
+                    return Err(error);
+                }
+
+                Ok(())
+            },
+            self.get_max_retries(),
+            self.retry_delay.as_millis() as u64,
+        )
+        .await
+        .map_err(|e| {
+            Error::Connection(format!(
+                "failed to advance replication slot '{slot_name}' to {lsn}: 
{e}"
+            ))
+        })?;
+        Ok(())
+    }
+
+    async fn apply_pending_operations(
+        &self,
+        operations: Vec<PendingOperation>,
+        deadline: tokio::time::Instant,
+    ) -> Result<Vec<PendingOperation>, Error> {
+        let mut failed_operations = Vec::new();
+        for operation in operations {
+            if !self.apply_pending_operation(&operation, deadline).await? {
+                failed_operations.push(operation);
+            }
+        }
+        Ok(failed_operations)
+    }
+
+    async fn apply_pending_operation(
+        &self,
+        operation: &PendingOperation,
+        deadline: tokio::time::Instant,
+    ) -> Result<bool, Error> {
+        let (operation_name, advances_slot) = match operation {
+            PendingOperation::ProcessRows { .. } => ("process rows", false),
+            PendingOperation::AdvanceReplicationSlot { .. } => ("advance 
replication slot", true),
+        };
+
+        let result =
+            tokio::time::timeout_at(deadline, 
self.execute_pending_operation(operation)).await;
+
+        match result {
+            Ok(Ok(())) => {
+                if advances_slot {
+                    self.consecutive_advance_failures
+                        .store(0, Ordering::Relaxed);
+                }
+                Ok(true)
+            }
+            Ok(Err(error)) => {
+                self.record_pending_operation_failure(operation_name, 
advances_slot, &error)?;
+                Ok(false)
+            }
+            Err(_) => {
+                self.record_pending_operation_failure(
+                    operation_name,
+                    advances_slot,
+                    "operation exceeded the shared 10s ACK batch budget",
+                )?;
+                Ok(false)
+            }
+        }
+    }
+
+    async fn execute_pending_operation(&self, operation: &PendingOperation) -> 
Result<(), Error> {

Review Comment:
   simplification: this private helper only dispatches for one caller. inline 
its match inside an async block passed to `timeout_at`, keeping pool lookup and 
both awaited operations inside the deadline.



##########
core/integration/tests/connectors/postgres/postgres_source.rs:
##########
@@ -127,6 +134,232 @@ async fn json_rows_source_produces_messages_to_iggy(
     }
 }
 
+#[iggy_harness(
+    cluster_nodes = 1,
+    server(connectors_runtime(config_path = 
"tests/connectors/postgres/source.toml")),
+    seed = seeds::connector_stream
+)]
+async fn 
given_delete_after_read_when_iggy_crashes_should_delete_only_after_redelivery(
+    harness: &mut TestHarness,
+    fixture: PostgresSourceDeleteFixture,
+) {
+    let pool = fixture.create_pool().await.expect("Failed to create pool");
+    fixture.create_table(&pool).await;
+
+    harness
+        .server_mut()
+        .stop_dependents()
+        .expect("Failed to stop connectors runtime");
+    harness
+        .server_mut()
+        .connectors_runtime_mut()
+        .expect("connectors runtime")
+        // Keep a failed send bounded instead of waiting indefinitely for Iggy 
to return.
+        .set_iggy_connection_options("reconnection_retries=0");
+    harness
+        .server_mut()
+        .start_dependents()
+        .await
+        .expect("Failed to restart connectors runtime");
+
+    let api_url = harness
+        .connectors_runtime()
+        .expect("connectors runtime")
+        .http_url();
+    let http = Client::new();
+    let errors_before_failure = source_stats(&http, &api_url)
+        .await
+        .expect("PostgreSQL source stats should be present")
+        .errors;
+
+    harness.kill_node(0).expect("Failed to kill Iggy server");
+
+    for index in 0..TEST_MESSAGE_COUNT {
+        fixture
+            .insert_row(&pool, &format!("row_{index}"), index as i32)
+            .await;
+    }
+
+    let failed_source = wait_for_source_errors(&http, &api_url, 
errors_before_failure + 2).await;
+    assert_eq!(failed_source.status, ConnectorStatus::Error);
+    assert_eq!(
+        fixture.count_rows(&pool).await,
+        TEST_MESSAGE_COUNT as i64,
+        "NACKed rows must not be deleted"
+    );
+
+    harness
+        .server_mut()
+        .stop_dependents()
+        .expect("Failed to stop connectors runtime");
+    harness
+        .restart_node(0)
+        .expect("Failed to restart Iggy server");
+    harness
+        .server_mut()
+        .connectors_runtime_mut()
+        .expect("connectors runtime")
+        .clear_iggy_connection_options();
+    harness
+        .server_mut()
+        .start_dependents()
+        .await
+        .expect("Failed to restart connectors runtime");
+
+    let client = harness.root_client().await.unwrap();
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+    let consumer_id: Identifier = "send_failure_consumer".try_into().unwrap();
+    let mut received = 0;
+
+    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
+        {
+            received += polled.messages.len();
+            if received >= TEST_MESSAGE_COUNT {
+                break;
+            }
+        }
+        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+    }
+
+    assert_eq!(
+        received, TEST_MESSAGE_COUNT,
+        "Rows polled during the failed send should be delivered after restart"
+    );
+
+    let mut remaining_rows = fixture.count_rows(&pool).await;
+    for _ in 0..POLL_ATTEMPTS {
+        if remaining_rows == 0 {
+            break;
+        }
+        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+        remaining_rows = fixture.count_rows(&pool).await;
+    }
+    assert_eq!(remaining_rows, 0, "ACKed rows should be deleted");
+
+    pool.close().await;
+}
+
+#[iggy_harness(
+    cluster_nodes = 1,
+    server(connectors_runtime(config_path = 
"tests/connectors/postgres/source.toml")),
+    seed = seeds::connector_stream
+)]
+async fn 
given_delivery_failure_when_iggy_restarts_should_redeliver_without_runtime_restart(
+    harness: &mut TestHarness,
+    fixture: PostgresSourceDeleteSlowPollFixture,
+) {
+    const REDELIVERY_ATTEMPTS: usize = POLL_ATTEMPTS * 3;
+
+    let pool = fixture.create_pool().await.expect("Failed to create pool");
+    fixture.create_table(&pool).await;
+
+    harness
+        .server_mut()
+        .stop_dependents()
+        .expect("Failed to stop connectors runtime");
+    harness
+        .server_mut()
+        .connectors_runtime_mut()
+        .expect("connectors runtime")
+        .set_iggy_connection_options("reconnection_retries=0");
+    harness
+        .server_mut()
+        .start_dependents()
+        .await
+        .expect("Failed to restart connectors runtime");
+
+    let api_url = harness
+        .connectors_runtime()
+        .expect("connectors runtime")
+        .http_url();
+    let http = Client::new();
+    let errors_before_failure = source_stats(&http, &api_url)
+        .await
+        .expect("PostgreSQL source stats should be present")
+        .errors;
+
+    harness.kill_node(0).expect("Failed to kill Iggy server");
+    fixture.insert_row(&pool, "single_nack", 1).await;
+
+    let failed_source = wait_for_source_errors(&http, &api_url, 
errors_before_failure + 1).await;
+    assert_eq!(failed_source.status, ConnectorStatus::Error);
+    assert_eq!(
+        fixture.count_rows(&pool).await,
+        1,
+        "NACKed row must not be deleted"
+    );
+
+    harness
+        .restart_node(0)
+        .expect("Failed to restart only the Iggy server");
+
+    let client = harness.root_client().await.unwrap();
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+    let consumer_id: Identifier = "nack_survival_consumer".try_into().unwrap();
+    let mut received = 0;
+
+    for _ in 0..REDELIVERY_ATTEMPTS {
+        if let Ok(polled) = client
+            .poll_messages(
+                &stream_id,
+                &topic_id,
+                None,
+                &Consumer::new(consumer_id.clone()),
+                &PollingStrategy::next(),
+                10,
+                true,
+            )
+            .await
+        {
+            received += polled.messages.len();
+            if received == 1 {
+                break;
+            }
+        }
+        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+    }
+
+    assert_eq!(received, 1, "NACKed row should be redelivered");
+
+    let mut remaining_rows = fixture.count_rows(&pool).await;
+    for _ in 0..REDELIVERY_ATTEMPTS {
+        if remaining_rows == 0 {
+            break;
+        }
+        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+        remaining_rows = fixture.count_rows(&pool).await;
+    }
+    assert_eq!(remaining_rows, 0, "ACKed row should be deleted");
+    wait_for_source_status(&http, &api_url, ConnectorStatus::Running).await;
+
+    pool.close().await;
+}
+
+async fn wait_for_source_status(http: &Client, api_url: &str, expected: 
ConnectorStatus) {

Review Comment:
   simplification: this status wait duplicates `postgres_source_cdc.rs:530`, 
and callers ignore its return value. share a unit-returning helper in 
`postgres/mod.rs` that reuses `source_stats`, preserving the retry count, sleep 
placement, and failure message.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -610,26 +895,28 @@ impl PostgresSource {
             .join(", ");
 
         if self.config.delete_after_read.unwrap_or(false) {
-            let delete_query =
-                format!("DELETE FROM {quoted_table} WHERE {quoted_pk} IN 
({ids_list})");
+            let delete_query = format!(
+                "DELETE FROM {quoted_table} WHERE {quoted_pk} IN 
({ids_list}){tracking_condition}"
+            );
 
             if self.verbose {
                 info!("Deleting {} processed rows from '{table}'", ids.len());
             } else {
                 debug!("Deleting {} processed rows from '{table}'", ids.len());
             }
 
-            sqlx::query(sqlx::AssertSqlSafe(delete_query))
-                .execute(pool)
-                .await
-                .map_err(|e| {
-                    error!("Failed to delete processed rows: {e}");
-                    Error::InvalidRecord
-                })?;
+            with_retry(
+                || 
sqlx::query(sqlx::AssertSqlSafe(delete_query.as_str())).execute(pool),

Review Comment:
   warning: dynamic cleanup queries still cache batch-specific SQL, displacing 
reusable plans when the cache fills. use `.persistent(false)` for changing 
cleanup and polling statements, while retaining caching for stable bound 
queries.



##########
core/connectors/sources/postgres_source/src/lib.rs:
##########
@@ -533,10 +659,14 @@ impl PostgresSource {
                 self.get_max_retries(),
                 self.retry_delay.as_millis() as u64,
             )
-            .await?;
+            .await
+            .map_err(|e| {
+                Error::Connection(format!("failed to poll PostgreSQL table 
'{table}': {e}"))
+            })?;
 
             let mut max_offset: Option<String> = None;
             let mut processed_ids: Vec<String> = Vec::new();

Review Comment:
   nit: this existing `processed_ids` vector grows without reserving space even 
though the fetched row count is known. initialize it with 
`Vec::with_capacity(rows.len())`.



##########
core/integration/tests/connectors/fixtures/postgres/source.rs:
##########
@@ -382,6 +382,141 @@ impl TestFixture for PostgresSourceDeleteFixture {
     }
 }
 
+/// The longer poll interval leaves time to restart Iggy before the SDK's
+/// consecutive-NACK limit stops the source.
+pub struct PostgresSourceDeleteSlowPollFixture {
+    inner: PostgresSourceDeleteFixture,
+}
+
+impl PostgresOps for PostgresSourceDeleteSlowPollFixture {
+    fn container(&self) -> &PostgresContainer {
+        self.inner.container()
+    }
+}
+
+impl PostgresSourceOps for PostgresSourceDeleteSlowPollFixture {
+    fn table_name(&self) -> &str {
+        self.inner.table_name()
+    }
+}
+
+impl PostgresSourceDeleteSlowPollFixture {

Review Comment:
   simplification: this wrapper and 
`core/integration/tests/connectors/fixtures/postgres/cdc.rs:226` repeat 
forwarding methods. use the `Deref` pattern from 
`core/integration/tests/connectors/fixtures/postgres/sink.rs:164`, retaining 
both named types, trait implementations, setup, and interval overrides.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to