This is an automated email from the ASF dual-hosted git repository.

loserwang1024 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 062ad34c8 [FLINK-40538][postgres] Skip WAL position search on idle 
publication for a fresh start (#4521)
062ad34c8 is described below

commit 062ad34c84d8351c0a34d56fedbb5b6b348291ea
Author: Joao Boto <[email protected]>
AuthorDate: Mon Oct 12 10:50:50 2026 +0200

    [FLINK-40538][postgres] Skip WAL position search on idle publication for a 
fresh start (#4521)
---
 .../flink-connector-postgres-cdc/pom.xml           |  11 ++
 .../PostgresStreamingChangeEventSource.java        |  27 ++-
 .../PostgresStreamingChangeEventSourceTest.java    | 182 +++++++++++++++++++++
 3 files changed, 215 insertions(+), 5 deletions(-)

diff --git 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/pom.xml
 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/pom.xml
index 7a0cd2714..421996e9c 100644
--- 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/pom.xml
+++ 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/pom.xml
@@ -29,6 +29,9 @@ limitations under the License.
     <name>flink-connector-postgres-cdc</name>
     <packaging>jar</packaging>
 
+    <properties>
+        <mockito.version>3.12.4</mockito.version>
+    </properties>
 
     <dependencies>
 
@@ -184,6 +187,14 @@ limitations under the License.
             </exclusions>
         </dependency>
 
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+
         <!-- tests will have log4j as the default logging framework available 
-->
 
     </dependencies>
diff --git 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
index e309a3b29..444caa20b 100644
--- 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
+++ 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
@@ -187,8 +187,17 @@ public class PostgresStreamingChangeEventSource
 
             this.lastCompletelyProcessedLsn = 
replicationStream.get().startLsn();
 
-            if (walPosition.searchingEnabled()) {
-                searchWalPosition(context, stream, walPosition);
+            // Only search for the WAL resume position when the stored offset 
has actually
+            // processed a position. On a fresh start (nothing processed yet) 
the search loop
+            // would block forever waiting for a decoded message: on an idle 
publication no WAL
+            // is produced, and the heartbeat action query that would generate 
some only runs
+            // from the main streaming loop, which this search precedes. 
Skipping the search here
+            // still starts streaming from the stored LSN, so no events are 
missed. This mirrors
+            // the fix Debezium shipped in 2.7, which added the 
hasCompletelyProcessedPosition()
+            // guard to searchingEnabled(); it relies on the DBZ-6635 
heartbeat fix backported in
+            // processMessages(), so the heartbeat action query can generate 
WAL meanwhile.
+            if (walPosition.searchingEnabled() && 
offsetContext.hasCompletelyProcessedPosition()) {
+                searchWalPosition(context, partition, offsetContext, stream, 
walPosition);
                 try {
                     if (!isInPreSnapshotCatchUpStreaming(offsetContext)) {
                         connection.commit();
@@ -356,9 +365,12 @@ public class PostgresStreamingChangeEventSource
                 noMessageIterations = 0;
                 lsnFlushingAllowed = true;
             } else {
-                if (offsetContext.hasCompletelyProcessedPosition()) {
-                    dispatcher.dispatchHeartbeatEvent(partition, 
offsetContext);
-                }
+                // Dispatch heartbeats also before any WAL message was 
processed (backport of
+                // Debezium DBZ-6635, shipped in 2.4): on a fresh start over 
an idle publication
+                // this is the only path that runs the heartbeat action query, 
which in turn
+                // generates the WAL that lets streaming make progress. 
Without it, skipping the
+                // WAL-position search below would only move the stall into 
this loop.
+                dispatcher.dispatchHeartbeatEvent(partition, offsetContext);
                 noMessageIterations++;
                 if (noMessageIterations >= THROTTLE_NO_MESSAGE_BEFORE_PAUSE) {
                     noMessageIterations = 0;
@@ -380,6 +392,8 @@ public class PostgresStreamingChangeEventSource
 
     private void searchWalPosition(
             ChangeEventSourceContext context,
+            PostgresPartition partition,
+            PostgresOffsetContext offsetContext,
             final ReplicationStream stream,
             final WalPositionLocator walPosition)
             throws SQLException, InterruptedException {
@@ -399,6 +413,9 @@ public class PostgresStreamingChangeEventSource
             if (receivedMessage) {
                 noMessageIterations = 0;
             } else {
+                // Dispatch heartbeats while searching so that the heartbeat 
action query can
+                // generate WAL on an idle publication and let the search 
terminate (Debezium 2.7).
+                dispatcher.dispatchHeartbeatEvent(partition, offsetContext);
                 noMessageIterations++;
                 if (noMessageIterations >= THROTTLE_NO_MESSAGE_BEFORE_PAUSE) {
                     noMessageIterations = 0;
diff --git 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSourceTest.java
 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSourceTest.java
new file mode 100644
index 000000000..6a2b70326
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSourceTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.
+ */
+
+package io.debezium.connector.postgresql;
+
+import org.apache.flink.cdc.connectors.postgres.testutils.TestHelper;
+
+import io.debezium.connector.postgresql.connection.Lsn;
+import io.debezium.connector.postgresql.connection.PostgresConnection;
+import 
io.debezium.connector.postgresql.connection.PostgresReplicationConnection;
+import io.debezium.connector.postgresql.connection.ReplicationStream;
+import io.debezium.connector.postgresql.connection.WalPositionLocator;
+import io.debezium.connector.postgresql.spi.Snapshotter;
+import io.debezium.pipeline.ErrorHandler;
+import io.debezium.pipeline.source.spi.ChangeEventSource;
+import io.debezium.relational.TableId;
+import io.debezium.util.Clock;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit test for the idle-publication fixes in {@link 
PostgresStreamingChangeEventSource}.
+ *
+ * <p>On an idle publication the WAL-position search loop blocks forever, 
because it waits for a
+ * decoded WAL message while the only mechanism that would produce one on a 
quiet database (the
+ * heartbeat action query) runs from heartbeat dispatch. The fix backports the 
two upstream Debezium
+ * changes:
+ *
+ * <ul>
+ *   <li>DBZ-6635 (2.4): dispatch heartbeats from the main streaming loop even 
before any position
+ *       has been completely processed, so the heartbeat action query can 
generate WAL on a fresh
+ *       start;
+ *   <li>the 2.7 guard only entering the WAL-position search when {@code 
searchingEnabled() &&
+ *       offsetContext.hasCompletelyProcessedPosition()}, plus dispatching 
heartbeats while
+ *       searching.
+ * </ul>
+ */
+class PostgresStreamingChangeEventSourceTest {
+
+    private PostgresConnectorConfig connectorConfig;
+    private PostgresOffsetContext.Loader offsetLoader;
+
+    @BeforeEach
+    public void beforeEach() {
+        this.connectorConfig = new 
PostgresConnectorConfig(TestHelper.defaultConfig().build());
+        this.offsetLoader = new 
PostgresOffsetContext.Loader(this.connectorConfig);
+    }
+
+    /**
+     * Builds the {@link WalPositionLocator} the same way {@code execute} does 
for a stored offset.
+     */
+    private static WalPositionLocator walPositionFor(PostgresOffsetContext 
offsetContext) {
+        Lsn lsn =
+                offsetContext.lastCompletelyProcessedLsn() != null
+                        ? offsetContext.lastCompletelyProcessedLsn()
+                        : offsetContext.lsn();
+        return new WalPositionLocator(offsetContext.lastCommitLsn(), lsn);
+    }
+
+    private static PostgresOffsetContext freshStartOffsetContext(
+            PostgresOffsetContext.Loader loader) {
+        // A fresh stream split start: the starting offset carries an LSN (the 
low watermark) but
+        // nothing has been completely processed yet.
+        final Map<String, Object> offsetValues = new HashMap<>();
+        offsetValues.put(SourceInfo.LSN_KEY, 12345L);
+        offsetValues.put(SourceInfo.TIMESTAMP_USEC_KEY, 67890L);
+        return loader.load(offsetValues);
+    }
+
+    @Test
+    void shouldNotSearchWalPositionOnFreshStart() {
+        final PostgresOffsetContext offsetContext = 
freshStartOffsetContext(offsetLoader);
+
+        // searchingEnabled() alone is true, so the pre-fix condition would 
enter the search loop
+        // and stall forever on an idle publication...
+        assertThat(walPositionFor(offsetContext).searchingEnabled()).isTrue();
+        // ...but the added guard is false on a fresh start, so the search is 
correctly skipped.
+        assertThat(offsetContext.hasCompletelyProcessedPosition())
+                .as(
+                        "WAL search must be skipped on a fresh start so an 
idle publication cannot stall it")
+                .isFalse();
+    }
+
+    @Test
+    void shouldSearchWalPositionWhenResumingFromProcessedOffset() {
+        // A resumed offset (e.g. after a checkpoint): a position has already 
been processed, so the
+        // search is still required to locate the exact resume point among 
already-seen LSNs.
+        final Map<String, Object> offsetValues = new HashMap<>();
+        offsetValues.put(SourceInfo.LSN_KEY, 12345L);
+        offsetValues.put(SourceInfo.TIMESTAMP_USEC_KEY, 67890L);
+        
offsetValues.put(PostgresOffsetContext.LAST_COMPLETELY_PROCESSED_LSN_KEY, 
12345L);
+
+        final PostgresOffsetContext offsetContext = 
offsetLoader.load(offsetValues);
+
+        // Both the pre-fix condition and the added guard are true, so the 
search still runs.
+        assertThat(walPositionFor(offsetContext).searchingEnabled()).isTrue();
+        assertThat(offsetContext.hasCompletelyProcessedPosition())
+                .as("WAL search must still run when resuming from an 
already-processed position")
+                .isTrue();
+    }
+
+    @Test
+    void shouldStreamAndDispatchHeartbeatsOnIdlePublicationFreshStart() throws 
Exception {
+        // Fresh start over an idle publication: the WAL-position search must 
be skipped and the
+        // main streaming loop must keep dispatching heartbeats (which run the 
heartbeat action
+        // query) even though nothing has a completely processed position. 
Without the DBZ-6635
+        // backport the heartbeat dispatch stays blocked behind 
hasCompletelyProcessedPosition(),
+        // no WAL is ever produced and the job stalls in the main loop instead 
of the search.
+        final PostgresOffsetContext offsetContext = 
freshStartOffsetContext(offsetLoader);
+        assertThat(offsetContext.hasCompletelyProcessedPosition()).isFalse();
+
+        final PostgresTaskContext taskContext = 
mock(PostgresTaskContext.class);
+        when(taskContext.getConfig()).thenReturn(connectorConfig);
+        @SuppressWarnings("unchecked")
+        final PostgresEventDispatcher<TableId> dispatcher = 
mock(PostgresEventDispatcher.class);
+        final Snapshotter snapshotter = mock(Snapshotter.class);
+        when(snapshotter.shouldStream()).thenReturn(true);
+        final PostgresConnection connection = mock(PostgresConnection.class);
+        final ErrorHandler errorHandler = mock(ErrorHandler.class);
+        final PostgresReplicationConnection replicationConnection =
+                mock(PostgresReplicationConnection.class);
+        final ReplicationStream stream = mock(ReplicationStream.class);
+        // Simulate an idle publication: no message is ever received.
+        when(stream.readPending(any())).thenReturn(false);
+        when(stream.startLsn()).thenReturn(Lsn.valueOf(12345L));
+        when(replicationConnection.startStreaming(any(Lsn.class), 
any(WalPositionLocator.class)))
+                .thenReturn(stream);
+
+        final PostgresStreamingChangeEventSource source =
+                new PostgresStreamingChangeEventSource(
+                        connectorConfig,
+                        snapshotter,
+                        connection,
+                        dispatcher,
+                        errorHandler,
+                        Clock.system(),
+                        mock(PostgresSchema.class),
+                        taskContext,
+                        replicationConnection);
+
+        // Run exactly two polls of the streaming loop, then stop the source.
+        final AtomicInteger polls = new AtomicInteger();
+        final ChangeEventSource.ChangeEventSourceContext context =
+                () -> polls.getAndIncrement() < 2;
+
+        source.execute(context, new PostgresPartition("test_server"), 
offsetContext);
+
+        // execute() swallows throwables into the error handler.
+        verify(errorHandler, never()).setProducerThrowable(any());
+        // One heartbeat dispatch per poll, although no message was ever 
received.
+        verify(dispatcher, times(2)).dispatchHeartbeatEvent(any(), 
eq(offsetContext));
+        // The WAL-position search was skipped: streaming kept the initial 
connection.
+        verify(replicationConnection, never()).reconnect();
+    }
+}

Reply via email to