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 3a0b76814 [FLINK-40853][postgres] Skip LSN commit for checkpoints 
without a stream offset (#4566)
3a0b76814 is described below

commit 3a0b768145064cb64e2b9715af04330e8684b18e
Author: κΉ€λ™ν™˜ <[email protected]>
AuthorDate: Wed Oct 21 12:03:54 2026 +0900

    [FLINK-40853][postgres] Skip LSN commit for checkpoints without a stream 
offset (#4566)
---
 .../source/fetch/PostgresStreamFetchTask.java      |  50 ++--
 .../source/reader/PostgresSourceReaderTest.java    | 272 ++++++++++++++++++++-
 2 files changed, 294 insertions(+), 28 deletions(-)

diff --git 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/fetch/PostgresStreamFetchTask.java
 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/fetch/PostgresStreamFetchTask.java
index a3d28a12c..893db82d1 100644
--- 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/fetch/PostgresStreamFetchTask.java
+++ 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/fetch/PostgresStreamFetchTask.java
@@ -124,33 +124,29 @@ public class PostgresStreamFetchTask implements 
FetchTask<SourceSplitBase> {
     }
 
     public void commitCurrentOffset(@Nullable Offset offsetToCommit) {
-        if (streamSplitReadTask != null && streamSplitReadTask.offsetContext 
!= null) {
-            PostgresOffsetContext postgresOffsetContext = 
streamSplitReadTask.offsetContext;
-
-            // only extracting and storing the lsn of the last commit
-            Long commitLsn =
-                    (Long)
-                            postgresOffsetContext
-                                    .getOffset()
-                                    
.get(PostgresOffsetContext.LAST_COMMIT_LSN_KEY);
-
-            if (offsetToCommit != null) {
-                // We should commit the checkpoint's LSN instead of 
postgresOffsetContext's LSN to
-                // the slot.
-                // If the checkpoint succeeds and a table UPDATE message 
arrives before the
-                // notifyCheckpoint is called, which is represented as a 
BEGIN/UPDATE/COMMIT WAL
-                // event sequence. The LSN of postgresOffsetContext will be 
updated to the LSN of
-                // the COMMIT event. Committing the COMMIT LSN to the slot is 
incorrect because if a
-                // failover occurs after the successful commission, Flink will 
recover from that
-                // checkpoint and consume WAL starting from the slot LSN that 
is the LSN of COMMIT
-                // event, rather than from the checkpoint's LSN. Therefore, 
UPDATE messages cannot
-                // be consumed, resulting in data loss.
-                commitLsn = ((PostgresOffset) 
offsetToCommit).getLsn().asLong();
-            }
-
-            if (commitLsn != null
-                    && (lastCommitLsn == null
-                            || 
Lsn.valueOf(commitLsn).compareTo(Lsn.valueOf(lastCommitLsn)) > 0)) {
+        if (offsetToCommit == null) {
+            // No stream offset was recorded for the checkpoint, e.g. it was 
taken before the
+            // stream split was assigned, or while newly added tables were 
being snapshotted. The
+            // LSN of postgresOffsetContext may already be beyond the offset 
of the latest
+            // completed checkpoint, so committing it could lose data for the 
reason below.
+            LOG.debug("Skip committing offset as the checkpoint has no stream 
offset.");
+            return;
+        }
+        if (streamSplitReadTask != null) {
+            // We should commit the checkpoint's LSN instead of 
postgresOffsetContext's LSN to
+            // the slot.
+            // If the checkpoint succeeds and a table UPDATE message arrives 
before the
+            // notifyCheckpoint is called, which is represented as a 
BEGIN/UPDATE/COMMIT WAL
+            // event sequence. The LSN of postgresOffsetContext will be 
updated to the LSN of
+            // the COMMIT event. Committing the COMMIT LSN to the slot is 
incorrect because if a
+            // failover occurs after the successful commission, Flink will 
recover from that
+            // checkpoint and consume WAL starting from the slot LSN that is 
the LSN of COMMIT
+            // event, rather than from the checkpoint's LSN. Therefore, UPDATE 
messages cannot
+            // be consumed, resulting in data loss.
+            long commitLsn = ((PostgresOffset) 
offsetToCommit).getLsn().asLong();
+
+            if (lastCommitLsn == null
+                    || 
Lsn.valueOf(commitLsn).compareTo(Lsn.valueOf(lastCommitLsn)) > 0) {
                 lastCommitLsn = commitLsn;
 
                 Map<String, Object> offsets = new HashMap<>();
diff --git 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/source/reader/PostgresSourceReaderTest.java
 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/source/reader/PostgresSourceReaderTest.java
index 7ef016b97..2852e6c2d 100644
--- 
a/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/source/reader/PostgresSourceReaderTest.java
+++ 
b/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/source/reader/PostgresSourceReaderTest.java
@@ -35,6 +35,7 @@ import 
org.apache.flink.cdc.connectors.postgres.source.PostgresDialect;
 import org.apache.flink.cdc.connectors.postgres.source.PostgresSourceBuilder;
 import 
org.apache.flink.cdc.connectors.postgres.source.config.PostgresSourceConfig;
 import 
org.apache.flink.cdc.connectors.postgres.source.config.PostgresSourceConfigFactory;
+import 
org.apache.flink.cdc.connectors.postgres.source.events.OffsetCommitEvent;
 import org.apache.flink.cdc.connectors.postgres.source.offset.PostgresOffset;
 import 
org.apache.flink.cdc.connectors.postgres.source.offset.PostgresOffsetFactory;
 import org.apache.flink.cdc.connectors.postgres.testutils.RecordsFormatter;
@@ -48,6 +49,7 @@ import org.apache.flink.table.types.logical.LogicalType;
 import org.apache.flink.table.types.logical.RowType;
 import org.apache.flink.util.Collector;
 
+import io.debezium.connector.postgresql.connection.Lsn;
 import io.debezium.relational.TableId;
 import io.debezium.relational.history.TableChanges;
 import org.apache.kafka.connect.source.SourceRecord;
@@ -58,6 +60,8 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.ArrayList;
@@ -66,6 +70,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 
 import static 
io.debezium.connector.postgresql.connection.ReplicationConnection.Builder.DEFAULT_PUBLICATION_NAME;
 import static org.apache.flink.core.io.InputStatus.END_OF_INPUT;
@@ -76,6 +81,12 @@ import static org.assertj.core.api.Assertions.assertThat;
 public class PostgresSourceReaderTest extends PostgresTestBase {
     private static final String DB_NAME_PREFIX = "postgres";
     private static final String SCHEMA_NAME = "customer";
+    private static final DataType CUSTOMER_TYPE =
+            DataTypes.ROW(
+                    DataTypes.FIELD("Id", DataTypes.BIGINT()),
+                    DataTypes.FIELD("Name", DataTypes.STRING()),
+                    DataTypes.FIELD("address", DataTypes.STRING()),
+                    DataTypes.FIELD("phone_number", DataTypes.STRING()));
     private String slotName;
     private final UniqueDatabase customDatabase =
             new UniqueDatabase(
@@ -286,6 +297,235 @@ public class PostgresSourceReaderTest extends 
PostgresTestBase {
         return source.createReader(new TestingReaderContext());
     }
 
+    /**
+     * Checkpoints taken before the stream split is assigned (e.g. during the 
snapshot phase) have
+     * no stream offset. With a commit delay, such a checkpoint is still 
committed after the stream
+     * phase has started. The committed LSN must not go beyond the offset of 
the latest completed
+     * checkpoint, otherwise the changes in between are lost when restoring 
from it.
+     */
+    @Test
+    void testLsnCommitDoesNotPassLatestCompletedCheckpoint() throws Exception {
+        PostgresSourceConfig sourceConfig = createConfigFactory().create(0);
+        try (PostgresDialect dialect = new PostgresDialect(sourceConfig);
+                PostgresSourceReader reader = createStreamReader(3)) {
+            Lsn checkpointLsn =
+                    
streamOffsetLsn(completeCheckpointsAcrossStreamStart(reader, dialect));
+
+            // checkpoint 4 is committed by now, wait until the slot reaches it
+            Lsn confirmedFlushLsn =
+                    waitForConfirmedFlushLsn(lsn -> 
lsn.compareTo(checkpointLsn) >= 0, 15_000L);
+            assertThat(confirmedFlushLsn)
+                    .as(
+                            "confirmed_flush_lsn %s must not pass the offset 
%s of the latest"
+                                    + " completed checkpoint",
+                            confirmedFlushLsn, checkpointLsn)
+                    .isLessThanOrEqualTo(checkpointLsn);
+        }
+    }
+
+    /** Same as above, but restores from the latest completed checkpoint and 
checks the data. */
+    @Test
+    void testNoDataLossWhenRestoringAfterLsnCommit() throws Exception {
+        PostgresSourceConfig sourceConfig = createConfigFactory().create(0);
+        List<SourceSplitBase> checkpointState;
+        try (PostgresDialect dialect = new PostgresDialect(sourceConfig);
+                PostgresSourceReader reader = createStreamReader(3)) {
+            checkpointState = completeCheckpointsAcrossStreamStart(reader, 
dialect);
+            // wait until the slot receives what was committed
+            Lsn checkpointLsn = streamOffsetLsn(checkpointState);
+            waitForConfirmedFlushLsn(lsn -> lsn.compareTo(checkpointLsn) >= 0, 
15_000L);
+        }
+
+        // restore from checkpoint 4, the latest completed one
+        try (PostgresSourceReader reader = createStreamReader(3)) {
+            reader.start();
+            reader.addSplits(checkpointState);
+            waitForSlotActive(30_000L);
+            insertCustomer(3003);
+
+            assertThat(pollStreamRecords(reader, CUSTOMER_TYPE, 2, 30_000L))
+                    .contains(customerRecord(3003))
+                    .contains(customerRecord(3002));
+        }
+    }
+
+    /**
+     * With scan.newly-added-table.enabled, the enumerator turns offset commit 
off while newly added
+     * tables are snapshotted, and the reader drops the stream offsets of the 
checkpoints taken
+     * meanwhile. Once offset commit is turned on again, those checkpoints are 
still committed
+     * because of the commit delay. The committed LSN must not go beyond the 
offset of the latest
+     * completed checkpoint.
+     */
+    @Test
+    void 
testLsnCommitDoesNotPassLatestCompletedCheckpointAfterNewlyAddedTables() throws 
Exception {
+        PostgresSourceConfig sourceConfig = createConfigFactory().create(0);
+        try (PostgresDialect dialect = new PostgresDialect(sourceConfig);
+                PostgresSourceReader reader = createStreamReader(3, true)) {
+            reader.start();
+            reader.handleSourceEvents(new OffsetCommitEvent(true));
+            
reader.addSplits(Collections.singletonList(createStreamSplit(dialect)));
+            waitForSlotActive(30_000L);
+
+            insertCustomer(3001);
+            assertThat(pollStreamRecords(reader, CUSTOMER_TYPE, 1, 5_000L))
+                    .containsExactly(customerRecord(3001));
+            long checkpointId = 1L;
+            for (; checkpointId <= 3L; checkpointId++) {
+                reader.snapshotState(checkpointId);
+                reader.notifyCheckpointComplete(checkpointId);
+            }
+
+            // newly added tables are being snapshotted
+            reader.handleSourceEvents(new OffsetCommitEvent(false));
+            for (; checkpointId <= 6L; checkpointId++) {
+                reader.snapshotState(checkpointId);
+                reader.notifyCheckpointComplete(checkpointId);
+            }
+
+            // the snapshot of newly added tables has finished
+            reader.handleSourceEvents(new OffsetCommitEvent(true));
+            Lsn checkpointLsn = 
streamOffsetLsn(reader.snapshotState(checkpointId));
+
+            // a change after the latest checkpoint, which must be replayed 
when restoring from it
+            insertCustomer(3002);
+            pollStreamRecords(reader, CUSTOMER_TYPE, 1, 5_000L);
+            reader.notifyCheckpointComplete(checkpointId);
+
+            // the slot may already be at the offset of checkpoint 1, which 
equals that of
+            // checkpoint
+            // 7, so watch it for a while in case it passes the offset
+            Lsn confirmedFlushLsn =
+                    waitForConfirmedFlushLsn(lsn -> 
lsn.compareTo(checkpointLsn) > 0, 15_000L);
+            assertThat(confirmedFlushLsn)
+                    .as(
+                            "confirmed_flush_lsn %s must not pass the offset 
%s of the latest"
+                                    + " completed checkpoint",
+                            confirmedFlushLsn, checkpointLsn)
+                    .isLessThanOrEqualTo(checkpointLsn);
+        }
+    }
+
+    /**
+     * Takes checkpoints 1 to 3 before the stream split is assigned, then 
checkpoint 4 in the stream
+     * phase after change 3001, then reads change 3002, and completes 
checkpoints 1 to 7. Returns
+     * the state of checkpoint 4.
+     */
+    private List<SourceSplitBase> completeCheckpointsAcrossStreamStart(
+            PostgresSourceReader reader, PostgresDialect dialect) throws 
Exception {
+        reader.start();
+
+        // checkpoints taken before the stream split is assigned
+        reader.snapshotState(1L);
+        reader.snapshotState(2L);
+        reader.snapshotState(3L);
+
+        
reader.addSplits(Collections.singletonList(createStreamSplit(dialect)));
+        waitForSlotActive(30_000L);
+
+        insertCustomer(3001);
+        assertThat(pollStreamRecords(reader, CUSTOMER_TYPE, 1, 5_000L))
+                .containsExactly(customerRecord(3001));
+        List<SourceSplitBase> checkpointState = reader.snapshotState(4L);
+
+        // a change after checkpoint 4, which must be replayed when restoring 
from it. Keep
+        // polling so that the stream reader reads it and moves its offset 
context forward.
+        insertCustomer(3002);
+        pollStreamRecords(reader, CUSTOMER_TYPE, 1, 5_000L);
+
+        for (long checkpointId = 1L; checkpointId <= 7L; checkpointId++) {
+            if (checkpointId > 4L) {
+                reader.snapshotState(checkpointId);
+            }
+            reader.notifyCheckpointComplete(checkpointId);
+        }
+        return checkpointState;
+    }
+
+    private StreamSplit createStreamSplit(PostgresDialect dialect) {
+        PostgresOffsetFactory offsetFactory = new PostgresOffsetFactory();
+        return new StreamSplit(
+                StreamSplit.STREAM_SPLIT_ID,
+                offsetFactory.createInitialOffset(),
+                offsetFactory.createNoStoppingOffset(),
+                Collections.emptyList(),
+                
dialect.discoverDataCollectionSchemas(createConfigFactory().create(0)),
+                0);
+    }
+
+    private static String customerRecord(int id) {
+        return String.format("+I[%d, user, Beijing, 123]", id);
+    }
+
+    private void insertCustomer(int id) throws SQLException {
+        try (Connection conn =
+                        getJdbcConnection(POSTGRES_CONTAINER, 
customDatabase.getDatabaseName());
+                Statement stmt = conn.createStatement()) {
+            stmt.execute(
+                    String.format(
+                            "INSERT INTO customer.\"Customers\" VALUES (%d, 
'user', 'Beijing', '123')",
+                            id));
+        }
+    }
+
+    private static Lsn streamOffsetLsn(List<SourceSplitBase> splits) {
+        return splits.stream()
+                .filter(SourceSplitBase::isStreamSplit)
+                .map(split -> ((PostgresOffset) 
split.asStreamSplit().getStartingOffset()).getLsn())
+                .findFirst()
+                .orElseThrow(() -> new IllegalStateException("No stream split 
in state"));
+    }
+
+    /**
+     * Returns the first confirmed_flush_lsn that matches the condition, or 
the last one seen when
+     * the timeout elapses.
+     */
+    private Lsn waitForConfirmedFlushLsn(Predicate<Lsn> condition, long 
timeoutMillis)
+            throws Exception {
+        long deadline = System.currentTimeMillis() + timeoutMillis;
+        Lsn lsn = null;
+        try (Connection conn =
+                        getJdbcConnection(POSTGRES_CONTAINER, 
customDatabase.getDatabaseName());
+                PreparedStatement stmt =
+                        conn.prepareStatement(
+                                "SELECT confirmed_flush_lsn FROM 
pg_replication_slots"
+                                        + " WHERE slot_name = ?")) {
+            stmt.setString(1, slotName);
+            while (System.currentTimeMillis() < deadline) {
+                try (ResultSet rs = stmt.executeQuery()) {
+                    if (rs.next() && rs.getString(1) != null) {
+                        lsn = Lsn.valueOf(rs.getString(1));
+                        if (condition.test(lsn)) {
+                            return lsn;
+                        }
+                    }
+                }
+                Thread.sleep(200L);
+            }
+        }
+        return lsn;
+    }
+
+    /** Waits until the stream reader is connected to the replication slot. */
+    private void waitForSlotActive(long timeoutMillis) throws Exception {
+        long deadline = System.currentTimeMillis() + timeoutMillis;
+        try (Connection conn =
+                        getJdbcConnection(POSTGRES_CONTAINER, 
customDatabase.getDatabaseName());
+                PreparedStatement stmt =
+                        conn.prepareStatement(
+                                "SELECT active FROM pg_replication_slots WHERE 
slot_name = ?")) {
+            stmt.setString(1, slotName);
+            while (System.currentTimeMillis() < deadline) {
+                try (ResultSet rs = stmt.executeQuery()) {
+                    if (rs.next() && rs.getBoolean(1)) {
+                        return;
+                    }
+                }
+                Thread.sleep(200L);
+            }
+        }
+        throw new IllegalStateException("Replication slot " + slotName + " is 
not active");
+    }
+
     @ParameterizedTest
     @ValueSource(booleans = {true, false})
     void testMultipleSnapshotSplit(boolean skipBackFill) throws Exception {
@@ -487,6 +727,24 @@ public class PostgresSourceReaderTest extends 
PostgresTestBase {
         return formatter.format(output.getResults());
     }
 
+    /** Polls until at least {@code expectedCount} records are read or the 
timeout elapses. */
+    private List<String> pollStreamRecords(
+            PostgresSourceReader sourceReader,
+            DataType recordType,
+            int expectedCount,
+            long timeoutMillis)
+            throws Exception {
+        final SimpleReaderOutput output = new SimpleReaderOutput();
+        long deadline = System.currentTimeMillis() + timeoutMillis;
+        while (output.getResults().size() < expectedCount
+                && System.currentTimeMillis() < deadline) {
+            if (sourceReader.pollNext(output) != MORE_AVAILABLE) {
+                Thread.sleep(100L);
+            }
+        }
+        return new RecordsFormatter(recordType).format(output.getResults());
+    }
+
     private List<String> consumeStreamRecords(
             PostgresSourceReader sourceReader, DataType recordType, int size) 
throws Exception {
         // Poll all the n records of the single split.
@@ -518,10 +776,22 @@ public class PostgresSourceReaderTest extends 
PostgresTestBase {
     }
 
     private PostgresSourceReader createStreamReader() throws Exception {
+        return createStreamReader(1);
+    }
+
+    private PostgresSourceReader createStreamReader(final int 
lsnCommitCheckpointsDelay)
+            throws Exception {
+        return createStreamReader(lsnCommitCheckpointsDelay, false);
+    }
+
+    private PostgresSourceReader createStreamReader(
+            final int lsnCommitCheckpointsDelay, boolean 
scanNewlyAddedTableEnabled)
+            throws Exception {
         final PostgresOffsetFactory offsetFactory = new 
PostgresOffsetFactory();
         final PostgresSourceConfigFactory configFactory = 
createConfigFactory();
         configFactory.startupOptions(StartupOptions.latest());
-        configFactory.setLsnCommitCheckpointsDelay(1);
+        configFactory.setLsnCommitCheckpointsDelay(lsnCommitCheckpointsDelay);
+        configFactory.scanNewlyAddedTableEnabled(scanNewlyAddedTableEnabled);
         PostgresDialect dialect = new PostgresDialect(configFactory.create(0));
         final PostgresSourceBuilder.PostgresIncrementalSource<?> source =
                 new PostgresSourceBuilder.PostgresIncrementalSource<>(

Reply via email to