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

mxm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new b61ef96a34 Flink: Fix dynamic sink HASH keying of records sent to the 
table updater (#18602)
b61ef96a34 is described below

commit b61ef96a34bd1054fca4a705ee73c7567f84781b
Author: zhengxiang378928908-code <[email protected]>
AuthorDate: Wed Oct 28 18:34:53 2026 +0800

    Flink: Fix dynamic sink HASH keying of records sent to the table updater 
(#18602)
---
 .../flink/sink/dynamic/DynamicRecordProcessor.java |  13 +-
 .../flink/sink/dynamic/HashKeyGenerator.java       |  10 +-
 .../flink/sink/dynamic/TestDynamicIcebergSink.java | 151 +++++++++++++++++++++
 .../flink/sink/dynamic/TestHashKeyGenerator.java   |  46 +++++++
 4 files changed, 207 insertions(+), 13 deletions(-)

diff --git 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java
 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java
index ba2d816ae9..e0ed8b8a26 100644
--- 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java
+++ 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java
@@ -159,15 +159,10 @@ class DynamicRecordProcessor<T> extends 
ProcessFunction<T, DynamicRecordInternal
             newData.f1,
             isForward);
       } else {
-        // Shuffled records with immediateUpdate=false go to the update side 
output
-        int writerKey =
-            hashKeyGenerator.generateKey(
-                data,
-                foundSchema.resolvedTableSchema() != null
-                    ? foundSchema.resolvedTableSchema()
-                    : data.schema(),
-                foundSpec != null ? foundSpec : data.spec(),
-                data.rowData());
+        // Shuffled records with immediateUpdate=false go to the update side 
output.
+        // Key them with the record's own schema, spec and row: the raw row is 
laid out by the
+        // record's schema, so the table's schema or spec cannot be used with 
it.
+        int writerKey = hashKeyGenerator.generateKey(data);
         context.output(
             updateStream,
             new DynamicRecordInternal(
diff --git 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java
 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java
index d884696414..c60dd9b0ce 100644
--- 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java
+++ 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java
@@ -38,6 +38,7 @@ import org.apache.iceberg.flink.sink.PartitionKeySelector;
 import org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.types.Types;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -66,7 +67,7 @@ class HashKeyGenerator {
     this.keySelectorCache = new LRUCache<>(maxCacheSize);
   }
 
-  int generateKey(DynamicRecord dynamicRecord) throws Exception {
+  int generateKey(DynamicRecord dynamicRecord) {
     return generateKey(dynamicRecord, null, null, null);
   }
 
@@ -317,14 +318,15 @@ class HashKeyGenerator {
 
   /**
    * Cache key for the {@link KeySelector}. Only contains the {@link Schema} 
and the {@link
-   * PartitionSpec} if their ids are not provided.
+   * PartitionSpec} if their ids are not provided. The schema is compared by 
its struct, because
+   * {@link Schema} has no value equality and callers may pass a new instance 
for every record.
    */
   static class SelectorKey {
     private final String tableName;
     private final String branch;
     private final Integer schemaId;
     private final Integer specId;
-    private final Schema schema;
+    private final Types.StructType schema;
     private final PartitionSpec spec;
     private final Set<String> equalityFields;
     private final DistributionMode distributionMode;
@@ -344,7 +346,7 @@ class HashKeyGenerator {
       this.branch = branch;
       this.schemaId = tableSchemaId;
       this.specId = tableSpecId;
-      this.schema = tableSchemaId == null ? schema : null;
+      this.schema = tableSchemaId == null ? schema.asStruct() : null;
       this.spec = tableSpecId == null ? spec : null;
       this.equalityFields = equalityFields;
       this.distributionMode = distributionMode;
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
index 9e2f00bca0..26565089bd 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java
@@ -21,11 +21,13 @@ package org.apache.iceberg.flink.sink.dynamic;
 import static org.apache.iceberg.flink.TestFixtures.DATABASE;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.tuple;
 
 import java.io.IOException;
 import java.io.Serializable;
 import java.nio.ByteBuffer;
 import java.time.Duration;
+import java.time.LocalDate;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -291,6 +293,155 @@ class TestDynamicIcebergSink extends 
TestFlinkIcebergSinkBase {
         resolvedSchema.getColumnDataTypes().toArray(DataType[]::new));
   }
 
+  /**
+   * Two writer-schema variants of one table with independently assigned field 
IDs: event_date has
+   * id 2 / position 1 in variant A and id 3 / position 2 in variant B. Each 
variant brings its own
+   * identity(event_date) spec. Records alternate between the variants with 
HASH distribution.
+   */
+  @Test
+  void testHashDistributionWithAlternatingSchemaVariants() throws Exception {
+    String tableName = "alternating_variants";
+    Schema variantA =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.StringType.get()),
+            Types.NestedField.optional(2, "event_date", Types.DateType.get()));
+    Schema variantB =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.StringType.get()),
+            Types.NestedField.optional(2, "extra", Types.StringType.get()),
+            Types.NestedField.optional(3, "event_date", Types.DateType.get()));
+    PartitionSpec specA = 
PartitionSpec.builderFor(variantA).identity("event_date").build();
+    PartitionSpec specB = 
PartitionSpec.builderFor(variantB).identity("event_date").build();
+
+    DynamicIcebergDataImpl a1 =
+        new DynamicIcebergDataImpl(variantA, tableName, 
SnapshotRef.MAIN_BRANCH, specA);
+    a1.rowProvided = Row.of("a1", LocalDate.of(2026, 10, 1));
+    DynamicIcebergDataImpl b1 =
+        new DynamicIcebergDataImpl(variantB, tableName, 
SnapshotRef.MAIN_BRANCH, specB);
+    b1.rowProvided = Row.of("b1", "x1", LocalDate.of(2026, 10, 2));
+    DynamicIcebergDataImpl a2 =
+        new DynamicIcebergDataImpl(variantA, tableName, 
SnapshotRef.MAIN_BRANCH, specA);
+    a2.rowProvided = Row.of("a2", LocalDate.of(2026, 10, 3));
+    DynamicIcebergDataImpl b2 =
+        new DynamicIcebergDataImpl(variantB, tableName, 
SnapshotRef.MAIN_BRANCH, specB);
+    b2.rowProvided = Row.of("b2", "x2", LocalDate.of(2026, 10, 4));
+
+    env =
+        StreamExecutionEnvironment.getExecutionEnvironment(
+            MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG);
+    executeDynamicSink(Lists.newArrayList(a1, b1, a2, b2), env, false, 1, 
null);
+
+    Table table = 
CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, tableName));
+    List<Record> actual;
+    try (CloseableIterable<Record> records = 
IcebergGenerics.read(table).build()) {
+      actual = Lists.newArrayList(records);
+    }
+    assertThat(actual)
+        .extracting(r -> r.getField("id"), r -> r.getField("extra"), r -> 
r.getField("event_date"))
+        .containsExactlyInAnyOrder(
+            tuple("a1", null, LocalDate.of(2026, 10, 1)),
+            tuple("b1", "x1", LocalDate.of(2026, 10, 2)),
+            tuple("a2", null, LocalDate.of(2026, 10, 3)),
+            tuple("b2", "x2", LocalDate.of(2026, 10, 4)));
+  }
+
+  /**
+   * The table exists with (id, event_date); records list the columns in the 
other order, so they
+   * need a data conversion, and go to a branch that does not exist yet, so 
they take the update
+   * path although the table schema and spec are known.
+   */
+  @Test
+  void testHashDistributionToNewBranchWithDataConversion() throws Exception {
+    String tableName = "hash_new_branch_conversion";
+    Schema tableSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.StringType.get()),
+            Types.NestedField.optional(2, "event_date", Types.DateType.get()));
+    PartitionSpec tableSpec = 
PartitionSpec.builderFor(tableSchema).identity("event_date").build();
+    CATALOG_EXTENSION
+        .catalog()
+        .createTable(TableIdentifier.of(DATABASE, tableName), tableSchema, 
tableSpec);
+
+    Schema reordered =
+        new Schema(
+            Types.NestedField.optional(1, "event_date", Types.DateType.get()),
+            Types.NestedField.optional(2, "id", Types.StringType.get()));
+    PartitionSpec reorderedSpec =
+        PartitionSpec.builderFor(reordered).identity("event_date").build();
+
+    DynamicIcebergDataImpl r1 =
+        new DynamicIcebergDataImpl(reordered, tableName, "b1", reorderedSpec);
+    r1.rowProvided = Row.of(LocalDate.of(2026, 10, 1), "r1");
+    DynamicIcebergDataImpl r2 =
+        new DynamicIcebergDataImpl(reordered, tableName, "b1", reorderedSpec);
+    r2.rowProvided = Row.of(LocalDate.of(2026, 10, 2), "r2");
+
+    env =
+        StreamExecutionEnvironment.getExecutionEnvironment(
+            MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG);
+    executeDynamicSink(Lists.newArrayList(r1, r2), env, false, 1, null);
+
+    Table table = 
CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, tableName));
+    List<Record> actual;
+    try (CloseableIterable<Record> records =
+        
IcebergGenerics.read(table).useSnapshot(table.refs().get("b1").snapshotId()).build())
 {
+      actual = Lists.newArrayList(records);
+    }
+    assertThat(actual)
+        .extracting(r -> r.getField("id"), r -> r.getField("event_date"))
+        .containsExactlyInAnyOrder(
+            tuple("r1", LocalDate.of(2026, 10, 1)), tuple("r2", 
LocalDate.of(2026, 10, 2)));
+  }
+
+  /**
+   * The table exists with (id, event_date) and a main branch, and records 
list the columns in the
+   * other order. No table update is needed, so the row is converted to the 
table layout before it
+   * is keyed.
+   */
+  @Test
+  void testHashDistributionWithReorderedColumns() throws Exception {
+    String tableName = "hash_reordered_columns";
+    Schema tableSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.StringType.get()),
+            Types.NestedField.optional(2, "event_date", Types.DateType.get()));
+    PartitionSpec tableSpec = 
PartitionSpec.builderFor(tableSchema).identity("event_date").build();
+    CATALOG_EXTENSION
+        .catalog()
+        .createTable(TableIdentifier.of(DATABASE, tableName), tableSchema, 
tableSpec)
+        .newFastAppend()
+        .commit();
+
+    Schema reordered =
+        new Schema(
+            Types.NestedField.optional(1, "event_date", Types.DateType.get()),
+            Types.NestedField.optional(2, "id", Types.StringType.get()));
+    PartitionSpec reorderedSpec =
+        PartitionSpec.builderFor(reordered).identity("event_date").build();
+
+    DynamicIcebergDataImpl r1 =
+        new DynamicIcebergDataImpl(reordered, tableName, 
SnapshotRef.MAIN_BRANCH, reorderedSpec);
+    r1.rowProvided = Row.of(LocalDate.of(2026, 10, 1), "r1");
+    DynamicIcebergDataImpl r2 =
+        new DynamicIcebergDataImpl(reordered, tableName, 
SnapshotRef.MAIN_BRANCH, reorderedSpec);
+    r2.rowProvided = Row.of(LocalDate.of(2026, 10, 2), "r2");
+
+    env =
+        StreamExecutionEnvironment.getExecutionEnvironment(
+            MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG);
+    executeDynamicSink(Lists.newArrayList(r1, r2), env, false, 1, null);
+
+    Table table = 
CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, tableName));
+    List<Record> actual;
+    try (CloseableIterable<Record> records = 
IcebergGenerics.read(table).build()) {
+      actual = Lists.newArrayList(records);
+    }
+    assertThat(actual)
+        .extracting(r -> r.getField("id"), r -> r.getField("event_date"))
+        .containsExactlyInAnyOrder(
+            tuple("r1", LocalDate.of(2026, 10, 1)), tuple("r2", 
LocalDate.of(2026, 10, 2)));
+  }
+
   @Test
   void testWrite() throws Exception {
     List<DynamicIcebergDataImpl> rows =
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java
index 0040b367e9..837de70d3d 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java
@@ -686,6 +686,52 @@ class TestHashKeyGenerator {
     assertThat(writeKey1).isEqualTo(writeKey3);
   }
 
+  @Test
+  void testCacheHitForEqualSchemaInstances() throws Exception {
+    int maxWriteParallelism = 8;
+    HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism);
+    Map<HashKeyGenerator.SelectorKey, KeySelector<RowData, Integer>> 
keySelectorCache =
+        generator.getKeySelectorCache();
+
+    PartitionSpec unpartitioned = PartitionSpec.unpartitioned();
+    DynamicRecord record1 =
+        new DynamicRecord(
+            TABLE_IDENTIFIER,
+            BRANCH,
+            new Schema(SCHEMA.columns()),
+            GenericRowData.of(1, StringData.fromString("foo")),
+            unpartitioned,
+            DistributionMode.NONE,
+            2);
+    DynamicRecord record2 =
+        new DynamicRecord(
+            TABLE_IDENTIFIER,
+            BRANCH,
+            new Schema(SCHEMA.columns()),
+            GenericRowData.of(1, StringData.fromString("foo")),
+            unpartitioned,
+            DistributionMode.NONE,
+            2);
+
+    int writeKey1 = generator.generateKey(record1);
+    int writeKey2 = generator.generateKey(record2);
+    // The second record reuses the key selector, so the round robin moves on
+    assertThat(keySelectorCache).hasSize(1);
+    assertThat(writeKey2).isNotEqualTo(writeKey1);
+
+    DynamicRecord record3 =
+        new DynamicRecord(
+            TABLE_IDENTIFIER,
+            BRANCH,
+            new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+            GenericRowData.of(1),
+            unpartitioned,
+            DistributionMode.NONE,
+            2);
+    generator.generateKey(record3);
+    assertThat(keySelectorCache).hasSize(2);
+  }
+
   @Test
   void testCacheMissOnWriteParallelismChange() throws Exception {
     int maxWriteParallelism = 8;

Reply via email to