Skip to content

[FLINK-40640][iceberg] Fix duplicate rows when an UPDATE changes the partition value - #4536

Merged
ferenc-csaky merged 1 commit into
apache:masterfrom
dataengineervishal:iceberg-partition-update-fix
Sep 18, 2026
Merged

ferenc-csaky merged 1 commit into
apache:masterfrom
dataengineervishal:iceberg-partition-update-fix

Conversation

@dataengineervishal

Copy link
Copy Markdown
Contributor

What is the purpose of this pull request?

Fixes FLINK-40640.

When the Iceberg pipeline connector processes an UPDATE that changes the value of a partitioned column, the row ends up duplicated in the Iceberg table instead of moved: a stale copy stays in the old partition, and the correct copy lands in the new partition. The stale copy never goes away on its own, and running compaction afterward does not help either, since rewrite_data_files only applies deletes that already exist and no delete marker for the stale row was ever created.

The cause is in RowDataUtils#convertDataChangeEventToRowData. Every UPDATE was collapsed into a single after-image row tagged RowKind.INSERT, and the before-image was discarded entirely. Iceberg's equality deletes are partition-scoped (a delete file only applies to data files in the same partition with a lower sequence number), so the equality-delete that Iceberg's upsert writer synthesizes was derived solely from the after-image and routed to the new partition, never touching the row sitting in the old partition.

Brief change log

  • RowDataUtils#convertDataChangeEventToRowData now returns List<RowData> instead of a single RowData. For UPDATE, it returns a DELETE row built from the before-image followed by an INSERT row built from the after-image, so the old partition gets a real delete instead of losing the before-image entirely. INSERT/REPLACE/DELETE are unchanged (single row).
  • IcebergWriter#write() writes each row returned by the conversion instead of assuming exactly one row per event.

Verifying this change

This change added tests and can be verified as follows:

  • Added testUpdateChangingPartitionValueDeletesFromOldPartition in IcebergWriterTest, which creates a table partitioned by region, inserts and commits id=1, region=north, then applies an UPDATE moving it to region=east, and asserts the table contains exactly id=1, region=east afterward (no duplicate). This test fails against the pre-fix code with the exact duplicate described above, and passes with the fix.
  • All existing tests in IcebergWriterTest continue to pass.

Documentation

  • Does this pull request introduce a new feature? no
  • If yes, how is the feature documented? not applicable

@dataengineervishal

Copy link
Copy Markdown
Contributor Author

@ferenc-csaky Can you please take a look and approve this?

@dataengineervishal

Copy link
Copy Markdown
Contributor Author

@flinkbot run azure

…partition value

Iceberg equality deletes are partition-scoped: a delete file only
applies to data files in the same partition with a lower sequence
number. RowDataUtils.convertDataChangeEventToRowData collapsed every
UPDATE into a single after-image row tagged RowKind.INSERT, discarding
the before-image entirely. When the update changed a partitioned
column's value, the resulting equality-delete was derived from and
routed to the new partition, so the stale row in the old partition was
never deleted.

Split UPDATE into two rows: a DELETE built from the before-image
(routed to and deleting from the old partition) followed by an INSERT
built from the after-image. IcebergWriter.write() now writes each row
returned by the (now list-returning) conversion.

@ferenc-csaky ferenc-csaky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, straightforward change.

One non-blocking follow-up: AFAIK for updates that do not change either the Iceberg partition or identifier/equality key, the explicit DELETE is redundant because the upsert writer already creates an equality delete for the subsequent INSERT.

Avoiding that extra delete could reduce delete-file overhead for update-heavy workloads. That optimization should compare the actual Iceberg partition transform result (not only raw partition columns) and identifier fields, so that's more like an improvement handled separately from this correctness fix.

If no objections until then I'll merge this by EOD tomorrow.

case UPDATE:
return Arrays.asList(
toRowData(dataChangeEvent.before(), fieldGetters, RowKind.DELETE),
toRowData(dataChangeEvent.after(), fieldGetters, RowKind.INSERT));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great fix!

One optimization suggestion:

Since the Iceberg upsert writer automatically calls deleteKey() on every INSERT row, the explicit DELETE row is redundant for non-partitioned tables — the auto-generated equality-delete already covers the old row in the same partition. The bug only affects partitioned tables where the partition value changes.

Suggest only splitting when the table is partitioned. TableSchemaWrapper already holds the Schema, so adding a cached isPartitioned flag keeps per-record overhead to a single field read:

// TableSchemaWrapper
private final boolean partitioned;

public TableSchemaWrapper(Schema schema, ZoneId zoneId) {
    ...
    this.partitioned = !schema.partitionKeys().isEmpty();
}

public boolean isPartitioned() {
    return partitioned;
}
// RowDataUtils — UPDATE case
if (isPartitioned) {
    return Arrays.asList(
            toRowData(before, fieldGetters, RowKind.DELETE),
            toRowData(after, fieldGetters, RowKind.INSERT));
} else {
    return Collections.singletonList(
            toRowData(after, fieldGetters, RowKind.INSERT));
}

Overall LGTM, the optimization is non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ivyanquan Nice suggestion! I was also thinking along similar lines. I was considering checking whether the partition column value is the same in the before and after values. If they are the same, we could send only the INSERT otherwise, we would send both the DELETE for the old row and the INSERT for the new row.

I’ll include the isPartitioned check as well, since it will avoid the unnecessary overhead for non-partitioned tables.

I’ll take this up as a separate optimization task.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, looking forward to your updates. I was concerned that checking whether the partition value actually changed on every record might have a performance impact, so I'd suggest only checking whether the table is partitioned. Feel free to factor performance into your consideration.

@ferenc-csaky
ferenc-csaky merged commit 017de09 into apache:master Sep 18, 2026
24 checks passed
@ferenc-csaky

Copy link
Copy Markdown
Contributor

@lvyanquan is there a plan for a 3.6.1 release? Just asking if you think it would worth to backport this commit to release-3.6 or 3.7 is already on the horizon?

@lvyanquan

Copy link
Copy Markdown
Contributor

@lvyanquan is there a plan for a 3.6.1 release? Just asking if you think it would worth to backport this commit to release-3.6 or 3.7 is already on the horizon?

There is currently no release plan for 3.6.1. Just wait for 3.7 — it's scheduled to be released in the coming weeks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants