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

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


The following commit(s) were added to refs/heads/release-3.7 by this push:
     new ea6595f39 [FLINK-41057][iceberg] Fix append-only update and delete 
handling (#4588) (#4606)
ea6595f39 is described below

commit ea6595f39ff6aaa8ca211e35a46ceea12394833d
Author: Kunni <[email protected]>
AuthorDate: Mon Nov 2 15:53:15 2026 +0800

    [FLINK-41057][iceberg] Fix append-only update and delete handling (#4588) 
(#4606)
---
 .../iceberg/sink/utils/RowDataUtils.java           |  20 +-
 .../connectors/iceberg/sink/v2/IcebergWriter.java  |  50 ++-
 .../iceberg/sink/utils/RowDataUtilsTest.java       | 118 +++++++
 .../sink/v2/IcebergWriterDeleteCapabilityTest.java | 375 +++++++++++++++++++++
 .../iceberg/sink/v2/IcebergWriterTest.java         | 128 ++++++-
 5 files changed, 670 insertions(+), 21 deletions(-)

diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
index 51b6a8595..cf72a8c42 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
@@ -31,25 +31,37 @@ import java.util.List;
 public class RowDataUtils {
 
     /**
-     * Convert a {@link DataChangeEvent} to one or two {@link RowData}.
+     * Convert a {@link DataChangeEvent} to zero, one or two {@link RowData}.
      *
-     * <p>An {@code UPDATE} is split into a {@code DELETE} built from the 
before-image followed by
-     * an {@code INSERT} built from the after-image, rather than a single 
after-image row. Iceberg
+     * <p>For tables with equality fields, an {@code UPDATE} is split into a 
{@code DELETE} built
+     * from the before-image followed by an {@code INSERT} built from the 
after-image. Iceberg
      * derives both the equality-delete key and the delete's target partition 
from the row passed to
      * the writer; collapsing an UPDATE to just the after-image (as before) 
means a partition key
      * change is never deleted from its old partition, leaving a stale 
duplicate behind.
+     *
+     * <p>Append-only writers do not interpret {@link RowKind#DELETE}. For 
tables without equality
+     * fields, only the after-image of an update is appended, and deletes are 
ignored.
      */
     public static List<RowData> convertDataChangeEventToRowData(
-            DataChangeEvent dataChangeEvent, List<RecordData.FieldGetter> 
fieldGetters) {
+            DataChangeEvent dataChangeEvent,
+            List<RecordData.FieldGetter> fieldGetters,
+            boolean supportsEqualityDeletes) {
         switch (dataChangeEvent.op()) {
             case INSERT:
             case REPLACE:
                 return Collections.singletonList(
                         toRowData(dataChangeEvent.after(), fieldGetters, 
RowKind.INSERT));
             case DELETE:
+                if (!supportsEqualityDeletes) {
+                    return Collections.emptyList();
+                }
                 return Collections.singletonList(
                         toRowData(dataChangeEvent.before(), fieldGetters, 
RowKind.DELETE));
             case UPDATE:
+                if (!supportsEqualityDeletes) {
+                    return Collections.singletonList(
+                            toRowData(dataChangeEvent.after(), fieldGetters, 
RowKind.INSERT));
+                }
                 return Arrays.asList(
                         toRowData(dataChangeEvent.before(), fieldGetters, 
RowKind.DELETE),
                         toRowData(dataChangeEvent.after(), fieldGetters, 
RowKind.INSERT));
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
index 6945ed57f..f0afbc37d 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
@@ -64,7 +64,7 @@ public class IcebergWriter
 
     public static final long DEFAULT_MAX_FILE_SIZE = 256 * 1024 * 1024;
 
-    private Map<TableId, RowDataTaskWriterFactory> writerFactoryMap;
+    private Map<TableId, TableWriterFactory> writerFactoryMap;
 
     private Map<TableId, TaskWriter<RowData>> writerMap;
 
@@ -140,9 +140,10 @@ public class IcebergWriter
         return list;
     }
 
-    private RowDataTaskWriterFactory getRowDataTaskWriterFactory(TableId 
tableId) {
+    private TableWriterFactory getTableWriterFactory(TableId tableId) {
         Table table = 
catalog.loadTable(TableIdentifier.parse(tableId.identifier()));
         RowType rowType = FlinkSchemaUtil.convert(table.schema());
+        List<Integer> equalityFieldIds = new 
ArrayList<>(table.schema().identifierFieldIds());
         RowDataTaskWriterFactory rowDataTaskWriterFactory =
                 new RowDataTaskWriterFactory(
                         table,
@@ -150,10 +151,12 @@ public class IcebergWriter
                         DEFAULT_MAX_FILE_SIZE,
                         FileFormat.fromString(DEFAULT_FILE_FORMAT),
                         new HashMap<>(),
-                        new ArrayList<>(table.schema().identifierFieldIds()),
+                        equalityFieldIds,
                         true);
         rowDataTaskWriterFactory.initialize(taskId, attemptId);
-        return rowDataTaskWriterFactory;
+        // Match the factory's actual append/delta writer selection, even for 
existing tables
+        // whose identifier fields differ from the incoming CDC schema's 
primary key.
+        return new TableWriterFactory(rowDataTaskWriterFactory, 
!equalityFieldIds.isEmpty());
     }
 
     @Override
@@ -161,14 +164,20 @@ public class IcebergWriter
         if (event instanceof DataChangeEvent) {
             DataChangeEvent dataChangeEvent = (DataChangeEvent) event;
             TableId tableId = dataChangeEvent.tableId();
-            writerFactoryMap.computeIfAbsent(tableId, 
this::getRowDataTaskWriterFactory);
-            TaskWriter<RowData> writer =
-                    writerMap.computeIfAbsent(
-                            tableId, tableId1 -> 
writerFactoryMap.get(tableId1).create());
+            TableWriterFactory writerFactory =
+                    writerFactoryMap.computeIfAbsent(tableId, 
this::getTableWriterFactory);
             TableSchemaWrapper tableSchemaWrapper = schemaMap.get(tableId);
             List<RowData> rowDataList =
                     RowDataUtils.convertDataChangeEventToRowData(
-                            dataChangeEvent, 
tableSchemaWrapper.getFieldGetters());
+                            dataChangeEvent,
+                            tableSchemaWrapper.getFieldGetters(),
+                            writerFactory.supportsEqualityDeletes);
+            if (rowDataList.isEmpty()) {
+                return;
+            }
+            TaskWriter<RowData> writer =
+                    writerMap.computeIfAbsent(
+                            tableId, tableId1 -> 
writerFactory.taskWriterFactory.create());
             for (RowData rowData : rowDataList) {
                 writer.write(rowData);
             }
@@ -193,13 +202,15 @@ public class IcebergWriter
 
     @Override
     public void flush(boolean flush) throws IOException {
-        // Clear the factory cache so the next write picks up the latest 
catalog schema.
-        // Writers keep running; schema-change splits are handled in 
flushTableWriter.
-        writerFactoryMap.clear();
+        // Keep each active writer paired with the factory that defines its 
delete capability.
+        // Factories without writers can reload the latest catalog schema on 
the next write.
+        writerFactoryMap.keySet().retainAll(writerMap.keySet());
     }
 
     private void flushTableWriter(TableId tableId) throws IOException {
         TaskWriter<RowData> writer = writerMap.remove(tableId);
+        // An ignored append-only DELETE can populate the factory without 
creating a writer.
+        writerFactoryMap.remove(tableId);
         // Advance even when no writer exists, to keep batchIndex in sync 
across subtasks.
         int batchIndex = tableBatchIndexMap.getOrDefault(tableId, 0);
         tableBatchIndexMap.put(tableId, batchIndex + 1);
@@ -216,7 +227,6 @@ public class IcebergWriter
                         batchIndex);
         temporaryWriteResult.add(writeResultWrapper);
         LOGGER.info(writeResultWrapper.buildDescription());
-        writerFactoryMap.remove(tableId);
     }
 
     private List<WriteResultWrapper> getWriteResult() throws IOException {
@@ -271,4 +281,18 @@ public class IcebergWriter
 
         catalog = null;
     }
+
+    /** Cache the writer factory and its delete capability from the same 
target-table schema. */
+    private static class TableWriterFactory {
+
+        private final RowDataTaskWriterFactory taskWriterFactory;
+
+        private final boolean supportsEqualityDeletes;
+
+        private TableWriterFactory(
+                RowDataTaskWriterFactory taskWriterFactory, boolean 
supportsEqualityDeletes) {
+            this.taskWriterFactory = taskWriterFactory;
+            this.supportsEqualityDeletes = supportsEqualityDeletes;
+        }
+    }
 }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtilsTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtilsTest.java
new file mode 100644
index 000000000..d251042da
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtilsTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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 org.apache.flink.cdc.connectors.iceberg.sink.utils;
+
+import org.apache.flink.cdc.common.data.RecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.types.RowKind;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for converting CDC events for append-only and equality-delete 
writers. */
+class RowDataUtilsTest {
+
+    private static final TableId TABLE_ID = 
TableId.parse("test.iceberg_table");
+
+    private static final Schema SCHEMA =
+            Schema.newBuilder()
+                    .physicalColumn("id", DataTypes.BIGINT().notNull())
+                    .physicalColumn("value", DataTypes.STRING())
+                    .build();
+
+    private final List<RecordData.FieldGetter> fieldGetters =
+            IcebergTypeUtils.createFieldGetters(SCHEMA, ZoneId.of("UTC"));
+
+    private final BinaryRecordDataGenerator generator =
+            new 
BinaryRecordDataGenerator(SCHEMA.getColumnDataTypes().toArray(new DataType[0]));
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testInsertAndReplacePreserveAfterImage(boolean 
supportsEqualityDeletes) {
+        RecordData after = record("new");
+        for (DataChangeEvent event :
+                Arrays.asList(
+                        DataChangeEvent.insertEvent(TABLE_ID, after),
+                        DataChangeEvent.replaceEvent(TABLE_ID, after, null))) {
+            List<RowData> rows =
+                    RowDataUtils.convertDataChangeEventToRowData(
+                            event, fieldGetters, supportsEqualityDeletes);
+            assertThat(rows).hasSize(1);
+            assertRow(rows.get(0), RowKind.INSERT, "new");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testUpdateUsesBeforeImageOnlyWithEqualityDeletes(boolean 
supportsEqualityDeletes) {
+        List<RowData> rows =
+                RowDataUtils.convertDataChangeEventToRowData(
+                        DataChangeEvent.updateEvent(TABLE_ID, record("old"), 
record("new")),
+                        fieldGetters,
+                        supportsEqualityDeletes);
+
+        if (supportsEqualityDeletes) {
+            assertThat(rows).hasSize(2);
+            assertRow(rows.get(0), RowKind.DELETE, "old");
+            assertRow(rows.get(1), RowKind.INSERT, "new");
+        } else {
+            assertThat(rows).hasSize(1);
+            assertRow(rows.get(0), RowKind.INSERT, "new");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testDeleteIsIgnoredWithoutEqualityDeletes(boolean 
supportsEqualityDeletes) {
+        List<RowData> rows =
+                RowDataUtils.convertDataChangeEventToRowData(
+                        DataChangeEvent.deleteEvent(TABLE_ID, record("old")),
+                        fieldGetters,
+                        supportsEqualityDeletes);
+
+        if (supportsEqualityDeletes) {
+            assertThat(rows).hasSize(1);
+            assertRow(rows.get(0), RowKind.DELETE, "old");
+        } else {
+            assertThat(rows).isEmpty();
+        }
+    }
+
+    private RecordData record(String value) {
+        return generator.generate(new Object[] {1L, 
BinaryStringData.fromString(value)});
+    }
+
+    private static void assertRow(RowData row, RowKind rowKind, String value) {
+        assertThat(row.getRowKind()).isEqualTo(rowKind);
+        assertThat(row.getLong(0)).isEqualTo(1L);
+        assertThat(row.getString(1).toString()).isEqualTo(value);
+    }
+}
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterDeleteCapabilityTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterDeleteCapabilityTest.java
new file mode 100644
index 000000000..cd2da2250
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterDeleteCapabilityTest.java
@@ -0,0 +1,375 @@
+/*
+ * 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 org.apache.flink.cdc.connectors.iceberg.sink.v2;
+
+import org.apache.flink.cdc.common.data.RecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.connectors.iceberg.sink.IcebergMetadataApplier;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.IcebergGenerics;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.io.CloseableIterable;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.nio.file.Path;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests target-table delete capabilities and their per-table factory cache. 
*/
+class IcebergWriterDeleteCapabilityTest {
+
+    @TempDir private Path temporaryFolder;
+
+    private final BinaryRecordDataGenerator generator =
+            new BinaryRecordDataGenerator(
+                    schema(false, false).getColumnDataTypes().toArray(new 
DataType[0]));
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testMixedTableDeleteCapabilities(boolean keyedTableFirst) throws 
Exception {
+        TableId keyedTable = TableId.parse("test.keyed_table");
+        TableId appendTable = TableId.parse("test.append_table");
+        List<TableId> writeOrder =
+                keyedTableFirst
+                        ? Arrays.asList(keyedTable, appendTable)
+                        : Arrays.asList(appendTable, keyedTable);
+        RecordData north = record(1L, "north");
+        RecordData east = record(1L, "east");
+
+        try (TestContext context = new TestContext()) {
+            Schema keyedSchema = schema(true, true);
+            Schema appendSchema = schema(false, true);
+            context.createTable(keyedTable, keyedSchema, keyedSchema);
+            context.createTable(appendTable, appendSchema, appendSchema);
+            for (TableId tableId : writeOrder) {
+                context.writer.write(DataChangeEvent.insertEvent(tableId, 
north), null);
+            }
+            context.commit();
+
+            // Rebuild both factories after the checkpoint, interleaving 
tables with opposite
+            // capabilities. The keyed row must move partitions; the append 
table keeps both.
+            for (TableId tableId : writeOrder) {
+                context.writer.write(DataChangeEvent.updateEvent(tableId, 
north, east), null);
+            }
+            context.commit();
+            assertThat(context.read(keyedTable)).containsExactly("1, east");
+            
assertThat(context.read(appendTable)).containsExactlyInAnyOrder("1, north", "1, 
east");
+
+            for (TableId tableId : writeOrder) {
+                context.writer.write(DataChangeEvent.deleteEvent(tableId, 
east), null);
+            }
+            context.commit();
+            assertThat(context.read(keyedTable)).isEmpty();
+            
assertThat(context.read(appendTable)).containsExactlyInAnyOrder("1, north", "1, 
east");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testExistingKeyedTargetWithoutCdcPrimaryKey(boolean partitioned) 
throws Exception {
+        TableId tableId = TableId.parse("test.existing_keyed_table");
+        RecordData north = record(1L, "north");
+        RecordData east = record(1L, "east");
+
+        try (TestContext context = new TestContext()) {
+            Schema targetSchema = schema(true, partitioned);
+            Schema cdcSchema = schema(false, partitioned);
+            assertThat(cdcSchema.primaryKeys()).isEmpty();
+            context.createTable(tableId, targetSchema, cdcSchema);
+            assertThat(
+                            context.catalog
+                                    
.loadTable(TableIdentifier.parse(tableId.identifier()))
+                                    .schema()
+                                    .identifierFieldIds())
+                    .isNotEmpty();
+
+            context.writer.write(DataChangeEvent.insertEvent(tableId, north), 
null);
+            context.commit();
+            context.writer.write(DataChangeEvent.updateEvent(tableId, north, 
east), null);
+            context.commit();
+            assertThat(context.read(tableId)).containsExactly("1, east");
+
+            // A source-schema PK check would incorrectly ignore this delete.
+            context.writer.write(DataChangeEvent.deleteEvent(tableId, east), 
null);
+            context.commit();
+            assertThat(context.read(tableId)).isEmpty();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testCatalogLoadsOnlyOnFactoryCacheMiss(boolean hasPrimaryKey) throws 
Exception {
+        TableId tableId = TableId.parse("test.cached_table");
+        int rowCount = 20;
+
+        try (TestContext context = new TestContext()) {
+            Schema schema = schema(hasPrimaryKey, false);
+            context.createTable(tableId, schema, schema);
+            AtomicInteger loadTableCalls = 
countWriterCatalogLoads(context.writer);
+
+            for (long id = 1; id <= rowCount; id++) {
+                context.writer.write(
+                        DataChangeEvent.insertEvent(tableId, record(id, 
"north")), null);
+                context.writer.write(
+                        DataChangeEvent.updateEvent(
+                                tableId, record(id, "north"), record(id, 
"east")),
+                        null);
+            }
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+
+            // Flush keeps each active writer paired with its factory.
+            context.writer.flush(false);
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+            for (long id = 1; id <= rowCount; id++) {
+                context.writer.write(
+                        DataChangeEvent.updateEvent(
+                                tableId, record(id, "east"), record(id, 
"south")),
+                        null);
+            }
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+            context.commit();
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+
+            // Checkpoint invalidation causes exactly one new load, even for a 
delete-only batch.
+            for (long id = 1; id <= rowCount; id++) {
+                context.writer.write(
+                        DataChangeEvent.deleteEvent(tableId, record(id, 
"south")), null);
+            }
+            assertThat(loadTableCalls.get()).isEqualTo(2);
+            context.commit();
+            assertThat(loadTableCalls.get()).isEqualTo(2);
+            assertThat(context.read(tableId)).hasSize(hasPrimaryKey ? 0 : 
rowCount * 3);
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+    void testFlushPreservesActiveWriterCapability(boolean hasPrimaryKey, 
boolean partitioned)
+            throws Exception {
+        TableId tableId = TableId.parse("test.active_table");
+        TableId otherTableId = TableId.parse("test.other_table");
+        RecordData north = record(1L, "north");
+        RecordData east = record(1L, "east");
+
+        try (TestContext context = new TestContext()) {
+            Schema schema = schema(hasPrimaryKey, partitioned);
+            context.createTable(tableId, schema, schema);
+            AtomicInteger loadTableCalls = 
countWriterCatalogLoads(context.writer);
+            context.writer.write(DataChangeEvent.insertEvent(tableId, north), 
null);
+
+            // A new table's FlushEvent must not detach another table's active 
writer.
+            context.writer.flush(false);
+            context.createTable(otherTableId, schema, schema);
+            context.catalog
+                    .loadTable(TableIdentifier.parse(tableId.identifier()))
+                    .updateSchema()
+                    .setIdentifierFields(hasPrimaryKey ? new String[0] : new 
String[] {"id"})
+                    .commit();
+            context.writer.write(DataChangeEvent.updateEvent(tableId, north, 
east), null);
+            context.commit();
+
+            if (hasPrimaryKey) {
+                assertThat(context.read(tableId)).containsExactly("1, east");
+            } else {
+                
assertThat(context.read(tableId)).containsExactlyInAnyOrder("1, north", "1, 
east");
+            }
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+
+            // After the checkpoint, both conversion and the new writer use 
the new capability.
+            context.writer.write(DataChangeEvent.deleteEvent(tableId, north), 
null);
+            context.writer.write(DataChangeEvent.deleteEvent(tableId, east), 
null);
+            context.commit();
+            assertThat(loadTableCalls.get()).isEqualTo(2);
+            if (hasPrimaryKey) {
+                assertThat(context.read(tableId)).containsExactly("1, east");
+            } else {
+                assertThat(context.read(tableId)).isEmpty();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testFlushInvalidatesFactoryWithoutWriter(boolean partitioned) throws 
Exception {
+        TableId tableId = TableId.parse("test.idle_table");
+        RecordData north = record(1L, "north");
+        RecordData east = record(1L, "east");
+
+        try (TestContext context = new TestContext()) {
+            Schema schema = schema(false, partitioned);
+            context.createTable(tableId, schema, schema);
+            context.writer.write(DataChangeEvent.insertEvent(tableId, north), 
null);
+            context.commit();
+            AtomicInteger loadTableCalls = 
countWriterCatalogLoads(context.writer);
+
+            context.writer.write(DataChangeEvent.deleteEvent(tableId, north), 
null);
+            assertThat(loadTableCalls.get()).isEqualTo(1);
+            context.writer.flush(false);
+            context.catalog
+                    .loadTable(TableIdentifier.parse(tableId.identifier()))
+                    .updateSchema()
+                    .setIdentifierFields("id")
+                    .commit();
+            context.writer.write(DataChangeEvent.updateEvent(tableId, north, 
east), null);
+            context.commit();
+
+            assertThat(loadTableCalls.get()).isEqualTo(2);
+            assertThat(context.read(tableId)).containsExactly("1, east");
+        }
+    }
+
+    private static AtomicInteger countWriterCatalogLoads(IcebergWriter writer) 
throws Exception {
+        Field catalogField = IcebergWriter.class.getDeclaredField("catalog");
+        catalogField.setAccessible(true);
+        Catalog delegate = (Catalog) catalogField.get(writer);
+        AtomicInteger loadTableCalls = new AtomicInteger();
+        Catalog countingCatalog =
+                (Catalog)
+                        Proxy.newProxyInstance(
+                                Catalog.class.getClassLoader(),
+                                new Class<?>[] {Catalog.class},
+                                (proxy, method, args) -> {
+                                    if ("loadTable".equals(method.getName())) {
+                                        loadTableCalls.incrementAndGet();
+                                    }
+                                    try {
+                                        return method.invoke(delegate, args);
+                                    } catch (InvocationTargetException e) {
+                                        throw e.getCause();
+                                    }
+                                });
+        catalogField.set(writer, countingCatalog);
+        return loadTableCalls;
+    }
+
+    private static Schema schema(boolean hasPrimaryKey, boolean partitioned) {
+        Schema.Builder builder =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.BIGINT().notNull())
+                        .physicalColumn("region", 
DataTypes.STRING().notNull());
+        if (hasPrimaryKey) {
+            builder.primaryKey("id");
+        }
+        if (partitioned) {
+            builder.partitionKey("region");
+        }
+        return builder.build();
+    }
+
+    private RecordData record(long id, String region) {
+        return generator.generate(new Object[] {id, 
BinaryStringData.fromString(region)});
+    }
+
+    private class TestContext implements AutoCloseable {
+
+        private final Catalog catalog;
+
+        private final IcebergMetadataApplier metadataApplier;
+
+        private final IcebergWriter writer;
+
+        private final IcebergCommitter committer;
+
+        private TestContext() {
+            Map<String, String> catalogOptions = new HashMap<>();
+            catalogOptions.put("type", "hadoop");
+            catalogOptions.put(
+                    "warehouse", 
temporaryFolder.resolve(UUID.randomUUID().toString()).toString());
+            catalogOptions.put("cache-enabled", "false");
+            catalog =
+                    CatalogUtil.buildIcebergCatalog(
+                            "cdc-iceberg-catalog", catalogOptions, new 
Configuration());
+            metadataApplier = new IcebergMetadataApplier(catalogOptions);
+            writer =
+                    new IcebergWriter(
+                            catalogOptions,
+                            1,
+                            1,
+                            ZoneId.of("UTC"),
+                            0,
+                            UUID.randomUUID().toString(),
+                            UUID.randomUUID().toString(),
+                            new HashMap<>());
+            committer = new IcebergCommitter(catalogOptions, new HashMap<>());
+        }
+
+        private void createTable(TableId tableId, Schema targetSchema, Schema 
cdcSchema) {
+            metadataApplier.applySchemaChange(new CreateTableEvent(tableId, 
targetSchema));
+            try {
+                writer.write(new CreateTableEvent(tableId, cdcSchema), null);
+            } catch (java.io.IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        private void commit() throws Exception {
+            committer.commit(
+                    writer.prepareCommit().stream()
+                            .map(IcebergWriterTest.MockCommitRequestImpl::new)
+                            .collect(Collectors.toList()));
+        }
+
+        private List<String> read(TableId tableId) throws Exception {
+            List<String> result = new ArrayList<>();
+            try (CloseableIterable<Record> records =
+                    IcebergGenerics.read(
+                                    
catalog.loadTable(TableIdentifier.parse(tableId.identifier())))
+                            .build()) {
+                for (Record record : records) {
+                    result.add(record.getField("id") + ", " + 
record.getField("region"));
+                }
+            }
+            return result;
+        }
+
+        @Override
+        public void close() throws Exception {
+            writer.close();
+            committer.close();
+            if (catalog instanceof AutoCloseable) {
+                ((AutoCloseable) catalog).close();
+            }
+        }
+    }
+}
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
index 4eda834ed..b9cb71b5d 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
@@ -60,6 +60,8 @@ import org.apache.iceberg.types.Types;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import java.io.File;
 import java.math.BigDecimal;
@@ -649,15 +651,13 @@ public class IcebergWriterTest {
                 generator.generate(new Object[] {1L, 
BinaryStringData.fromString("b")});
 
         icebergWriter.write(DataChangeEvent.insertEvent(tableId, recordA), 
null);
-        // flush resets the factory cache but keeps the writer running, so the 
update
-        // lands in the same writer as the insert and uses a position delete.
+        // Flush keeps the active writer and its factory, so the update uses a 
position delete.
         icebergWriter.flush(false);
         icebergWriter.write(DataChangeEvent.updateEvent(tableId, recordA, 
recordB), null);
 
         Collection<WriteResultWrapper> writeResults = 
icebergWriter.prepareCommit();
 
-        // Both writes went through the same writer (batchIndex 0) since flush 
only
-        // cleared the factory. Position delete handles dedup within the 
snapshot.
+        // Both writes use the same writer (batchIndex 0) and deduplicate 
within the snapshot.
         List<Integer> batchIndexes =
                 writeResults.stream()
                         .map(WriteResultWrapper::getBatchIndex)
@@ -1647,6 +1647,126 @@ public class IcebergWriterTest {
         Assertions.assertThat(result).containsExactly("1, east");
     }
 
+    @ParameterizedTest
+    @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+    void testAppendOnlyUpdatesAndDeletes(boolean partitioned, boolean 
cdcHasPrimaryKey)
+            throws Exception {
+        Map<String, String> catalogOptions = new HashMap<>();
+        catalogOptions.put("type", "hadoop");
+        catalogOptions.put(
+                "warehouse",
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString());
+        catalogOptions.put("cache-enabled", "false");
+        Catalog catalog =
+                CatalogUtil.buildIcebergCatalog(
+                        "cdc-iceberg-catalog", catalogOptions, new 
Configuration());
+        TableId tableId = TableId.parse("test.iceberg_table");
+        Schema.Builder schemaBuilder =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.BIGINT().notNull())
+                        .physicalColumn("region", 
DataTypes.STRING().notNull());
+        if (partitioned) {
+            schemaBuilder.partitionKey("region");
+        }
+        Schema targetSchema = schemaBuilder.build();
+        IcebergMetadataApplier metadataApplier = new 
IcebergMetadataApplier(catalogOptions);
+        metadataApplier.applySchemaChange(new CreateTableEvent(tableId, 
targetSchema));
+        Assertions.assertThat(
+                        
catalog.loadTable(TableIdentifier.parse(tableId.identifier()))
+                                .schema()
+                                .identifierFieldIds())
+                .isEmpty();
+
+        // An existing append-only target must stay append-only even if the 
CDC schema has a PK.
+        Schema cdcSchema =
+                cdcHasPrimaryKey
+                        ? Schema.newBuilder()
+                                .setColumns(targetSchema.getColumns())
+                                .partitionKey(targetSchema.partitionKeys())
+                                .primaryKey("id")
+                                .build()
+                        : targetSchema;
+        BinaryRecordDataGenerator generator =
+                new BinaryRecordDataGenerator(
+                        targetSchema.getColumnDataTypes().toArray(new 
DataType[0]));
+        RecordData north =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("north")});
+        RecordData east =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("east")});
+        RecordData south =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("south")});
+
+        try (IcebergWriter writer =
+                        new IcebergWriter(
+                                catalogOptions,
+                                1,
+                                1,
+                                ZoneId.of("UTC"),
+                                0,
+                                UUID.randomUUID().toString(),
+                                UUID.randomUUID().toString(),
+                                new HashMap<>());
+                IcebergCommitter committer =
+                        new IcebergCommitter(catalogOptions, new HashMap<>())) 
{
+            writer.write(new CreateTableEvent(tableId, cdcSchema), null);
+            writer.write(DataChangeEvent.insertEvent(tableId, north), null);
+            writer.write(DataChangeEvent.updateEvent(tableId, north, east), 
null);
+            committer.commit(
+                    writer.prepareCommit().stream()
+                            .map(MockCommitRequestImpl::new)
+                            .collect(Collectors.toList()));
+            Assertions.assertThat(fetchTableContent(catalog, tableId, null))
+                    .containsExactlyInAnyOrder("1, north", "1, east");
+
+            // A delete-only checkpoint must neither append the old row nor 
create a writer.
+            writer.write(DataChangeEvent.deleteEvent(tableId, north), null);
+            Assertions.assertThat(writer.prepareCommit()).isEmpty();
+            Assertions.assertThat(fetchTableContent(catalog, tableId, null))
+                    .containsExactlyInAnyOrder("1, north", "1, east");
+
+            // The capability must also be correct after checkpoint cache 
invalidation.
+            writer.write(DataChangeEvent.updateEvent(tableId, east, south), 
null);
+            committer.commit(
+                    writer.prepareCommit().stream()
+                            .map(MockCommitRequestImpl::new)
+                            .collect(Collectors.toList()));
+            Assertions.assertThat(fetchTableContent(catalog, tableId, null))
+                    .containsExactlyInAnyOrder("1, north", "1, east", "1, 
south");
+
+            // An ignored delete caches a factory without a writer. Schema 
changes must still
+            // invalidate that factory so subsequent inserts use the new 
physical schema.
+            writer.write(DataChangeEvent.deleteEvent(tableId, south), null);
+            AddColumnEvent addColumn =
+                    new AddColumnEvent(
+                            tableId,
+                            Arrays.asList(
+                                    new AddColumnEvent.ColumnWithPosition(
+                                            new PhysicalColumn(
+                                                    "extra", 
DataTypes.STRING(), null))));
+            metadataApplier.applySchemaChange(addColumn);
+            writer.write(addColumn, null);
+            Schema evolvedSchema = 
SchemaUtils.applySchemaChangeEvent(cdcSchema, addColumn);
+            BinaryRecordDataGenerator evolvedGenerator =
+                    new BinaryRecordDataGenerator(
+                            evolvedSchema.getColumnDataTypes().toArray(new 
DataType[0]));
+            RecordData west =
+                    evolvedGenerator.generate(
+                            new Object[] {
+                                1L,
+                                BinaryStringData.fromString("west"),
+                                BinaryStringData.fromString("added")
+                            });
+            writer.write(DataChangeEvent.insertEvent(tableId, west), null);
+            committer.commit(
+                    writer.prepareCommit().stream()
+                            .map(MockCommitRequestImpl::new)
+                            .collect(Collectors.toList()));
+            Assertions.assertThat(fetchTableContent(catalog, tableId, null))
+                    .containsExactlyInAnyOrder(
+                            "1, north, null", "1, east, null", "1, south, 
null", "1, west, added");
+        }
+    }
+
     private static long countSnapshots(Table table) {
         long count = 0;
         for (Snapshot ignored : table.snapshots()) {

Reply via email to