Copilot commented on code in PR #4360:
URL: https://github.com/apache/flink-cdc/pull/4360#discussion_r3031533042


##########
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergCommitter.java:
##########
@@ -91,74 +98,133 @@ private void commit(List<WriteResultWrapper> 
writeResultWrappers) {
         if (writeResultWrappers.isEmpty()) {
             return;
         }
-        // all commits a same checkpoint-id
         long checkpointId = writeResultWrappers.get(0).getCheckpointId();
         String newFlinkJobId = writeResultWrappers.get(0).getJobId();
         String operatorId = writeResultWrappers.get(0).getOperatorId();
 
-        Map<TableId, List<WriteResult>> tableMap = new HashMap<>();
-        for (WriteResultWrapper writeResultWrapper : writeResultWrappers) {
-            List<WriteResult> writeResult =
-                    tableMap.getOrDefault(writeResultWrapper.getTableId(), new 
ArrayList<>());
-            writeResult.add(writeResultWrapper.getWriteResult());
-            tableMap.put(writeResultWrapper.getTableId(), writeResult);
-            LOGGER.info(writeResultWrapper.buildDescription());
+        Map<TableId, List<WriteResultWrapper>> tableMap = new HashMap<>();
+        for (WriteResultWrapper w : writeResultWrappers) {
+            tableMap.computeIfAbsent(w.getTableId(), k -> new 
ArrayList<>()).add(w);
+            LOGGER.info(w.buildDescription());
         }
-        for (Map.Entry<TableId, List<WriteResult>> entry : 
tableMap.entrySet()) {
+
+        for (Map.Entry<TableId, List<WriteResultWrapper>> entry : 
tableMap.entrySet()) {
             TableId tableId = entry.getKey();
 
+            // Sort ascending by batch index to guarantee correct Iceberg 
sequence number ordering.
+            // Equality-delete files in batch N will have sequence number > 
batch M (M < N), so
+            // they correctly supersede stale data written by earlier 
same-checkpoint batches.
+            List<WriteResultWrapper> batches = entry.getValue();
+            
batches.sort(Comparator.comparingInt(WriteResultWrapper::getBatchIndex));
+

Review Comment:
   `commit()` currently sorts and iterates a `List<WriteResultWrapper>` per 
table and commits each wrapper as its own Iceberg snapshot. In production, the 
committer receives committables from *all* writer subtasks 
(`committables.global()`), so there will be multiple wrappers with the same 
`batchIndex` (one per subtask). Committing them separately can break 
correctness: equality-deletes in one subtask’s wrapper may end up with a higher 
sequence number than data files from another subtask in the same logical batch 
and can incorrectly delete those rows. It also breaks idempotent retry because 
the “already committed batchIndex” check can’t distinguish partially committed 
subtasks within the same batchIndex.
   
   Commit should be done once per (tableId, checkpointId, batchIndex) by 
merging data/delete files from all wrappers with that batchIndex into a single 
Append/RowDelta snapshot, then proceed to the next batchIndex.



##########
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergCommitter.java:
##########
@@ -91,74 +98,133 @@ private void commit(List<WriteResultWrapper> 
writeResultWrappers) {
         if (writeResultWrappers.isEmpty()) {
             return;
         }
-        // all commits a same checkpoint-id
         long checkpointId = writeResultWrappers.get(0).getCheckpointId();
         String newFlinkJobId = writeResultWrappers.get(0).getJobId();
         String operatorId = writeResultWrappers.get(0).getOperatorId();
 
-        Map<TableId, List<WriteResult>> tableMap = new HashMap<>();
-        for (WriteResultWrapper writeResultWrapper : writeResultWrappers) {
-            List<WriteResult> writeResult =
-                    tableMap.getOrDefault(writeResultWrapper.getTableId(), new 
ArrayList<>());
-            writeResult.add(writeResultWrapper.getWriteResult());
-            tableMap.put(writeResultWrapper.getTableId(), writeResult);
-            LOGGER.info(writeResultWrapper.buildDescription());
+        Map<TableId, List<WriteResultWrapper>> tableMap = new HashMap<>();
+        for (WriteResultWrapper w : writeResultWrappers) {
+            tableMap.computeIfAbsent(w.getTableId(), k -> new 
ArrayList<>()).add(w);
+            LOGGER.info(w.buildDescription());
         }
-        for (Map.Entry<TableId, List<WriteResult>> entry : 
tableMap.entrySet()) {
+
+        for (Map.Entry<TableId, List<WriteResultWrapper>> entry : 
tableMap.entrySet()) {
             TableId tableId = entry.getKey();
 
+            // Sort ascending by batch index to guarantee correct Iceberg 
sequence number ordering.
+            // Equality-delete files in batch N will have sequence number > 
batch M (M < N), so
+            // they correctly supersede stale data written by earlier 
same-checkpoint batches.
+            List<WriteResultWrapper> batches = entry.getValue();
+            
batches.sort(Comparator.comparingInt(WriteResultWrapper::getBatchIndex));
+
             Table table =
                     catalog.loadTable(
                             TableIdentifier.of(tableId.getSchemaName(), 
tableId.getTableName()));
 
+            int startBatchIndex = 0;
             Snapshot snapshot = table.currentSnapshot();
             if (snapshot != null) {
                 Iterable<Snapshot> ancestors =
                         SnapshotUtil.ancestorsOf(snapshot.snapshotId(), 
table::snapshot);
-                long lastCheckpointId =
+                long lastCommittedCheckpointId =
                         getMaxCommittedCheckpointId(ancestors, newFlinkJobId, 
operatorId);
-                if (lastCheckpointId == checkpointId) {
+                if (lastCommittedCheckpointId >= checkpointId) {
                     LOGGER.warn(
                             "Checkpoint id {} has been committed to table {}, 
skipping",
                             checkpointId,
                             tableId.identifier());
                     continue;
                 }
+                ancestors = SnapshotUtil.ancestorsOf(snapshot.snapshotId(), 
table::snapshot);
+                startBatchIndex =
+                        getLastCommittedBatchIndex(
+                                        ancestors, newFlinkJobId, operatorId, 
checkpointId)
+                                + 1;
             }

Review Comment:
   `startBatchIndex` is computed from a *batch index* found in snapshot 
summaries, but it is later used as a *list position* (`for (int i = 
startBatchIndex; i < batches.size(); i++)`). This only works if `batches` 
contains exactly one element per batch index and indices are contiguous 
starting at 0. With multiple writer subtasks (multiple wrappers per 
batchIndex), `startBatchIndex` will point into the middle of the list and can 
either re-commit already committed wrappers (duplicates) or skip some wrappers 
(data loss).
   
   After grouping/merging by batchIndex, iterate over actual batchIndex values 
(or find the first group with `batchIndex > lastCommittedBatchIndex`) rather 
than using the batchIndex as a list offset.



##########
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:
##########
@@ -552,6 +556,485 @@ public void testWithRepeatCommit() throws Exception {
         Assertions.assertThat(result).containsExactlyInAnyOrder("1, char1", 
"2, char2");
     }
 
+    @Test
+    public void testNoDuplicateWhenFlushSplitsSamePkUpdatesWithinCheckpoint() 
throws Exception {
+        Map<String, String> catalogOptions = new HashMap<>();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.put("type", "hadoop");
+        catalogOptions.put("warehouse", warehouse);
+        catalogOptions.put("cache-enabled", "false");
+        Catalog catalog =
+                CatalogUtil.buildIcebergCatalog(
+                        "cdc-iceberg-catalog", catalogOptions, new 
Configuration());
+
+        String jobId = UUID.randomUUID().toString();
+        String operatorId = UUID.randomUUID().toString();
+        IcebergWriter icebergWriter =
+                new IcebergWriter(
+                        catalogOptions, 1, 1, ZoneId.systemDefault(), 0, 
jobId, operatorId);
+        IcebergMetadataApplier icebergMetadataApplier = new 
IcebergMetadataApplier(catalogOptions);
+
+        TableId tableId = TableId.parse("test.iceberg_table");
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        tableId,
+                        Schema.newBuilder()
+                                .physicalColumn("id", 
DataTypes.BIGINT().notNull())
+                                .physicalColumn("name", DataTypes.VARCHAR(100))
+                                .primaryKey("id")
+                                .build());
+        icebergMetadataApplier.applySchemaChange(createTableEvent);
+        icebergWriter.write(createTableEvent, null);
+
+        BinaryRecordDataGenerator generator =
+                new BinaryRecordDataGenerator(
+                        
createTableEvent.getSchema().getColumnDataTypes().toArray(new DataType[0]));
+
+        RecordData recordA =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("a")});
+        RecordData recordB =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("b")});
+
+        icebergWriter.write(DataChangeEvent.insertEvent(tableId, recordA), 
null);
+        // flush(false) is a no-op; both events reach the same writer so the 
position-delete
+        // mechanism within the writer handles dedup correctly.
+        icebergWriter.flush(false);
+        icebergWriter.write(DataChangeEvent.updateEvent(tableId, recordA, 
recordB), null);
+
+        Collection<WriteResultWrapper> writeResults = 
icebergWriter.prepareCommit();
+        IcebergCommitter icebergCommitter = new 
IcebergCommitter(catalogOptions);
+        Collection<Committer.CommitRequest<WriteResultWrapper>> collection =
+                
writeResults.stream().map(MockCommitRequestImpl::new).collect(Collectors.toList());
+        icebergCommitter.commit(collection);
+
+        List<String> result = fetchTableContent(catalog, tableId, null);
+        Assertions.assertThat(result).containsExactly("1, b");
+    }
+
+    /**
+     * Verifies that same-PK updates split across a schema-change-triggered 
writer flush within one
+     * checkpoint do not produce duplicate records.
+     *
+     * <p>Root cause: when a schema-change event forces {@code 
flushTableWriter}, the pre-change
+     * writes land in one {@code WriteResultWrapper} (batch 0) and the 
post-change writes land in
+     * another (batch 1). Committing both in a single Iceberg {@code RowDelta} 
would give all files
+     * the same sequence number N, so the equality-delete file from batch 1 
(seq=N) cannot delete
+     * the data file from batch 0 (seq=N) — Iceberg equality-delete semantics 
require strictly lower
+     * sequence numbers. The fix commits each batch as a separate sequential 
snapshot, giving batch
+     * 1 a higher sequence number so its equality-delete correctly supersedes 
batch 0's data.
+     */
+    @Test
+    public void testNoDuplicateWhenSchemaChangeFlushSplitsSamePkUpdates() 
throws Exception {
+        Map<String, String> catalogOptions = new HashMap<>();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.put("type", "hadoop");
+        catalogOptions.put("warehouse", warehouse);
+        catalogOptions.put("cache-enabled", "false");
+        Catalog catalog =
+                CatalogUtil.buildIcebergCatalog(
+                        "cdc-iceberg-catalog", catalogOptions, new 
Configuration());
+
+        String jobId = UUID.randomUUID().toString();
+        String operatorId = UUID.randomUUID().toString();
+        IcebergWriter icebergWriter =
+                new IcebergWriter(
+                        catalogOptions, 1, 1, ZoneId.systemDefault(), 0, 
jobId, operatorId);
+        IcebergMetadataApplier icebergMetadataApplier = new 
IcebergMetadataApplier(catalogOptions);

Review Comment:
   The new tests exercise batch-splitting behavior using a single 
`IcebergWriter` instance (one subtask). Since 
`IcebergSink#addPreCommitTopology` uses `committables.global()`, the committer 
will receive committables from multiple writer subtasks in production; it would 
be valuable to add a test that creates 2 writers (different `taskId`s), 
produces committables with the same `batchIndex` for a table, and verifies the 
committer handles them correctly (no duplicates / no accidental deletes) when 
committed together.



-- 
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