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

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 9903b6d83fbb feat(flink): support record index handling in dynamic 
bucket assign function (#19837)
9903b6d83fbb is described below

commit 9903b6d83fbb59eecf15a5a76206c11797b6239b
Author: Peter Huang <[email protected]>
AuthorDate: Sun Sep 6 23:50:39 2026 -0700

    feat(flink): support record index handling in dynamic bucket assign 
function (#19837)
---
 .../partitioner/DynamicBucketAssignFunction.java   |  10 ++
 .../index/DummyPartitionedIndexBackend.java        |   5 +
 .../partitioner/index/PartitionedIndexBackend.java |  14 +++
 .../partitioner/index/RecordLevelIndexBackend.java |  24 +++++
 .../TestDynamicBucketAssignFunction.java           | 108 +++++++++++++++++++++
 .../sink/partitioner/index/TestIndexBackends.java  |   3 +
 .../index/TestRecordLevelIndexBackend.java         |  42 ++++++++
 7 files changed, 206 insertions(+)

diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/DynamicBucketAssignFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/DynamicBucketAssignFunction.java
index 4ad34bdc406c..f1a2921ec0dd 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/DynamicBucketAssignFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/DynamicBucketAssignFunction.java
@@ -55,6 +55,11 @@ import org.apache.flink.util.Collector;
  * {@code recordKey -> fileGroupId} mapping. Existing keys are routed as 
updates to
  * the recorded file group; new keys are assigned by {@link BucketAssigner} 
and then
  * written back to the backend so the streaming metadata writer can persist 
the assignment to RLI.
+ *
+ * <p>Preloaded index records emitted by the bootstrap operators (e.g. {@code 
RLIBootstrapOperator},
+ * {@code TimeBoundedRLIBootstrapOperator}) carry an already known {@code 
recordKey -> fileGroupId}
+ * mapping and no row data. They are applied directly to the index backend to 
warm up the cache and
+ * are never routed through {@link BucketAssigner} or emitted downstream.
  */
 public class DynamicBucketAssignFunction
     extends KeyedProcessFunctionAdapter<String, HoodieFlinkInternalRow, 
HoodieFlinkInternalRow>
@@ -118,6 +123,11 @@ public class DynamicBucketAssignFunction
 
   @Override
   public void processElement(HoodieFlinkInternalRow record, Context ctx, 
Collector<HoodieFlinkInternalRow> out) throws Exception {
+    if (record.isIndexRecord()) {
+      indexBackend.bootstrap(record.getPartitionPath(), record.getRecordKey(), 
record.getFileId());
+      return;
+    }
+
     String partitionPath = record.getPartitionPath();
     String recordKey = record.getRecordKey();
     String fileGroupId = indexBackend.get(partitionPath, recordKey);
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/DummyPartitionedIndexBackend.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/DummyPartitionedIndexBackend.java
index 2fe8c4784294..b555aab806c6 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/DummyPartitionedIndexBackend.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/DummyPartitionedIndexBackend.java
@@ -39,6 +39,11 @@ public class DummyPartitionedIndexBackend implements 
PartitionedIndexBackend {
     // do nothing
   }
 
+  @Override
+  public void bootstrap(String partitionPath, String recordKey, String fileId) 
{
+    // do nothing
+  }
+
   @Override
   public void close() throws IOException {
     // do nothing
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/PartitionedIndexBackend.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/PartitionedIndexBackend.java
index 3a2e583f41d9..170985767c4b 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/PartitionedIndexBackend.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/PartitionedIndexBackend.java
@@ -44,4 +44,18 @@ public interface PartitionedIndexBackend extends 
IndexBackend {
    * @param fileId the file group id, usually the file id prefix produced by 
bucket assignment
    */
   void update(String partitionPath, String recordKey, String fileId);
+
+  /**
+   * Applies a preloaded {@code recordKey -> fileId} mapping to the given 
partition, e.g. one emitted
+   * by a bootstrap operator that has already scanned the persisted index.
+   *
+   * <p>Unlike {@link #update}, this does not trigger a scan of the persisted 
index to warm up the
+   * partition cache when it does not exist yet, and the mapping is not marked 
as a normal data
+   * update.
+   *
+   * @param partitionPath the partition path of the record
+   * @param recordKey the unique key identifying the record
+   * @param fileId the file group id already known for the record key
+   */
+  void bootstrap(String partitionPath, String recordKey, String fileId);
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordLevelIndexBackend.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordLevelIndexBackend.java
index 783a3bd83bef..3d19c2061ceb 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordLevelIndexBackend.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordLevelIndexBackend.java
@@ -121,6 +121,12 @@ public class RecordLevelIndexBackend implements 
PartitionedIndexBackend {
     cleanExecutor.runIfNecessary(() -> cleanIfNecessary(0L, partitionPath));
   }
 
+  @Override
+  public void bootstrap(String partitionPath, String recordKey, String fileId) 
{
+    BucketCache cache = getOrCreatePartitionCache(partitionPath);
+    cache.bootstrapRecordKey(recordKey, fileId);
+  }
+
   /**
    * Records the latest checkpoint id seen by this assign subtask.
    *
@@ -163,6 +169,24 @@ public class RecordLevelIndexBackend implements 
PartitionedIndexBackend {
     return cache;
   }
 
+  /**
+   * Returns the partition cache, creating an empty one without scanning the 
persisted index if it
+   * does not exist yet.
+   *
+   * <p>Used for preloaded mappings that are already known to be complete for 
the partition, e.g. one
+   * emitted by a bootstrap operator that has already scanned the persisted 
index upstream.
+   */
+  private BucketCache getOrCreatePartitionCache(String partitionPath) {
+    BucketCache cache = partitionBucketCaches.get(partitionPath);
+    if (cache != null) {
+      return cache;
+    }
+
+    cache = createBucketCache(partitionPath);
+    partitionBucketCaches.put(partitionPath, cache);
+    return cache;
+  }
+
   private BucketCache bootstrapPartition(String partitionPath) {
     BucketCache cache = createBucketCache(partitionPath);
     if 
(!metaClient.getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX))
 {
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestDynamicBucketAssignFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestDynamicBucketAssignFunction.java
index 277b5e61e4f9..51e9a5d176e0 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestDynamicBucketAssignFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestDynamicBucketAssignFunction.java
@@ -22,6 +22,7 @@ import org.apache.hudi.client.model.HoodieFlinkInternalRow;
 import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
 import org.apache.hudi.common.table.view.FileSystemViewStorageType;
 import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.index.HoodieIndex;
 import org.apache.hudi.sink.event.Correspondent;
 import org.apache.hudi.sink.partitioner.index.DummyPartitionedIndexBackend;
 import org.apache.hudi.sink.partitioner.index.PartitionedIndexBackend;
@@ -49,6 +50,7 @@ import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -106,6 +108,24 @@ class TestDynamicBucketAssignFunction {
     verify(indexBackend).update("partition", "key", "new-file");
   }
 
+  @Test
+  void testProcessIndexRecordBootstrapsIndexBackendWithoutBucketAssignment() 
throws Exception {
+    DynamicBucketAssignFunction function = new DynamicBucketAssignFunction(new 
Configuration());
+    PartitionedIndexBackend indexBackend = mock(PartitionedIndexBackend.class);
+    BucketAssigner bucketAssigner = mock(BucketAssigner.class);
+    setField(function, "indexBackend", indexBackend);
+    setField(function, "bucketAssigner", bucketAssigner);
+    HoodieFlinkInternalRow indexRecord = new HoodieFlinkInternalRow("key", 
"partition", "existing-file", "20260101000000");
+    List<HoodieFlinkInternalRow> output = new ArrayList<>();
+
+    function.processElement(indexRecord, null, collector(output));
+
+    assertEquals(0, output.size(), "Index records carry no row data and must 
not be emitted downstream");
+    verify(indexBackend).bootstrap("partition", "key", "existing-file");
+    verify(bucketAssigner, never()).addInsert("partition");
+    verify(bucketAssigner, never()).addUpdate("partition", "existing-file");
+  }
+
   @Test
   void testCheckpointLifecycleDelegatesToBackends() throws Exception {
     DynamicBucketAssignFunction function = new DynamicBucketAssignFunction(new 
Configuration());
@@ -166,6 +186,94 @@ class TestDynamicBucketAssignFunction {
     function.close();
   }
 
+  @Test
+  void testBootstrappedIndexRecordIsReusedByLaterUpdate() throws Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    conf.set(FlinkOptions.INDEX_TYPE, 
HoodieIndex.IndexType.RECORD_LEVEL_INDEX.name());
+    StreamerUtil.initTableIfNotExists(conf);
+    ViewStorageProperties.createProperties(
+        conf.get(FlinkOptions.PATH),
+        FileSystemViewStorageConfig.newBuilder()
+            .withStorageType(FileSystemViewStorageType.SPILLABLE_DISK)
+            .build(),
+        conf);
+    DynamicBucketAssignFunction function = new 
DynamicBucketAssignFunction(conf);
+    function.setRuntimeContext(new MockStreamingRuntimeContext(false, 1, 0));
+    function.open(new Configuration());
+    function.initializeState(mock(FunctionInitializationContext.class));
+
+    try {
+      // Simulates the preloaded index record emitted upstream by 
RLIBootstrapOperator /
+      // TimeBoundedRLIBootstrapOperator for a record whose file group mapping 
is already known.
+      HoodieFlinkInternalRow indexRecord =
+          new HoodieFlinkInternalRow("key", "partition", "bootstrapped-file", 
"20260101000000");
+      List<HoodieFlinkInternalRow> indexOutput = new ArrayList<>();
+      function.processElement(indexRecord, null, collector(indexOutput));
+      assertEquals(0, indexOutput.size(), "Index record must not be forwarded 
to the writer");
+
+      // A later update for the same key should be routed to the bootstrapped 
file group instead of
+      // being assigned a brand-new bucket.
+      HoodieFlinkInternalRow updateRecord = record("key", "partition", "U");
+      List<HoodieFlinkInternalRow> updateOutput = new ArrayList<>();
+      function.processElement(updateRecord, null, collector(updateOutput));
+
+      assertEquals(1, updateOutput.size());
+      assertEquals("bootstrapped-file", updateRecord.getFileId());
+      assertEquals("U", updateRecord.getInstantTime());
+    } finally {
+      function.close();
+    }
+  }
+
+  @Test
+  void testBootstrappedIndexRecordsDoNotAffectUnrelatedKeysInSamePartition() 
throws Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    conf.set(FlinkOptions.INDEX_TYPE, 
HoodieIndex.IndexType.RECORD_LEVEL_INDEX.name());
+    StreamerUtil.initTableIfNotExists(conf);
+    ViewStorageProperties.createProperties(
+        conf.get(FlinkOptions.PATH),
+        FileSystemViewStorageConfig.newBuilder()
+            .withStorageType(FileSystemViewStorageType.SPILLABLE_DISK)
+            .build(),
+        conf);
+    DynamicBucketAssignFunction function = new 
DynamicBucketAssignFunction(conf);
+    function.setRuntimeContext(new MockStreamingRuntimeContext(false, 1, 0));
+    function.open(new Configuration());
+    function.initializeState(mock(FunctionInitializationContext.class));
+
+    try {
+      // Preload two keys in the same partition, as a bootstrap operator would 
emit for all records it
+      // owns in that partition.
+      function.processElement(
+          new HoodieFlinkInternalRow("key1", "partition", 
"bootstrapped-file-1", "20260101000000"),
+          null, collector(new ArrayList<>()));
+      function.processElement(
+          new HoodieFlinkInternalRow("key2", "partition", 
"bootstrapped-file-2", "20260101000000"),
+          null, collector(new ArrayList<>()));
+
+      HoodieFlinkInternalRow update1 = record("key1", "partition", "U");
+      function.processElement(update1, null, collector(new ArrayList<>()));
+      assertEquals("bootstrapped-file-1", update1.getFileId());
+
+      HoodieFlinkInternalRow update2 = record("key2", "partition", "U");
+      function.processElement(update2, null, collector(new ArrayList<>()));
+      assertEquals("bootstrapped-file-2", update2.getFileId());
+
+      // A brand-new key in the same partition that was never preloaded must 
still go through bucket
+      // assignment as an insert rather than reusing a bootstrapped file group.
+      HoodieFlinkInternalRow newRecord = record("key3", "partition", "I");
+      List<HoodieFlinkInternalRow> newOutput = new ArrayList<>();
+      function.processElement(newRecord, null, collector(newOutput));
+
+      assertEquals(1, newOutput.size());
+      assertEquals("I", newRecord.getInstantTime());
+      assertNotEquals("bootstrapped-file-1", newRecord.getFileId());
+      assertNotEquals("bootstrapped-file-2", newRecord.getFileId());
+    } finally {
+      function.close();
+    }
+  }
+
   private static HoodieFlinkInternalRow record(String recordKey, String 
partitionPath, String operationType) {
     return new HoodieFlinkInternalRow(recordKey, partitionPath, operationType, 
new GenericRowData(0));
   }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestIndexBackends.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestIndexBackends.java
index 85d6feed642c..d124731b22e5 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestIndexBackends.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestIndexBackends.java
@@ -51,6 +51,9 @@ class TestIndexBackends {
 
     assertNull(backend.get("partition", "key"));
     backend.update("partition", "key", "file-id");
+    assertNull(backend.get("partition", "key"));
+    backend.bootstrap("partition", "key", "file-id");
+    assertNull(backend.get("partition", "key"));
     backend.close();
   }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordLevelIndexBackend.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordLevelIndexBackend.java
index c6f1a4191eed..83ee81f80ecc 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordLevelIndexBackend.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordLevelIndexBackend.java
@@ -219,6 +219,48 @@ public class TestRecordLevelIndexBackend {
     }
   }
 
+  @Test
+  public void testBootstrapCreatesCacheWithoutScanningPersistedIndex() throws 
Exception {
+    try (RecordLevelIndexBackend backend = createBackend()) {
+      // Metrics are intentionally left unregistered: bootstrapPartition() 
would NPE on the metrics
+      // field if it were invoked, so a clean call here proves bootstrap() 
skips the persisted-index scan.
+      backend.bootstrap("new-partition", "key1", "file-group-1");
+
+      assertEquals(1, backend.getPartitionBucketCaches().size());
+      assertEquals("file-group-1", backend.get("new-partition", "key1"));
+    }
+  }
+
+  @Test
+  public void testBootstrapReusesExistingPartitionCache() throws Exception {
+    try (RecordLevelIndexBackend backend = createBackend()) {
+      backend.registerMetrics(new UnregisteredMetricsGroup());
+      backend.update("partition", "existing-key", "existing-file");
+
+      backend.bootstrap("partition", "new-key", "new-file");
+
+      assertEquals(1, backend.getPartitionBucketCaches().size());
+      assertEquals("existing-file", backend.get("partition", "existing-key"));
+      assertEquals("new-file", backend.get("partition", "new-key"));
+    }
+  }
+
+  @Test
+  public void testBootstrapDoesNotMarkCacheAsRecentlyUpdated() throws 
Exception {
+    try (RecordLevelIndexBackend backend = createBackend()) {
+      backend.onCheckpoint(2L);
+      backend.bootstrap("bootstrapped", "key1", "file1");
+      backend.getPartitionBucketCaches().put("recent", 
cacheWithHeapSize(backend, 2 * ONE_MB, 2L));
+      backend.onCheckpointComplete(new 
TestCorrespondent(Collections.emptyMap()), 2L);
+
+      backend.cleanIfNecessary(0L, "recent");
+
+      
assertFalse(backend.getPartitionBucketCaches().containsKey("bootstrapped"),
+          "A bootstrapped-only cache must not be protected as a recent update, 
so it is evicted first");
+      assertTrue(backend.getPartitionBucketCaches().containsKey("recent"));
+    }
+  }
+
   @Test
   public void testLazyEvictReturnsWhenOnlyProtectedPartitionExceedsLimit() 
throws Exception {
     try (RecordLevelIndexBackend backend = createBackend()) {

Reply via email to