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 092e4c40c504 fix(flink): reclaim empty checkpoint caches in record
index (#20241)
092e4c40c504 is described below
commit 092e4c40c504723f6b534b994ca950b6df131bc0
Author: Shuo Cheng <[email protected]>
AuthorDate: Wed Oct 14 10:53:40 2026 +0800
fix(flink): reclaim empty checkpoint caches in record index (#20241)
* fix(flink): reclaim empty checkpoint caches in record index
* refactor(flink): separate empty cache cleanup from memory eviction
---------
Co-authored-by: danny0405 <[email protected]>
---
.../sink/partitioner/index/RecordIndexCache.java | 25 ++++++--
.../partitioner/index/TestRecordIndexCache.java | 69 ++++++++++++++++++++++
2 files changed, 88 insertions(+), 6 deletions(-)
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordIndexCache.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordIndexCache.java
index 8f78bd70d34f..1084c7534841 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordIndexCache.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/RecordIndexCache.java
@@ -36,7 +36,7 @@ import org.apache.flink.configuration.Configuration;
import java.io.Closeable;
import java.io.IOException;
import java.util.Comparator;
-import java.util.NavigableMap;
+import java.util.Map;
import java.util.TreeMap;
/**
@@ -182,16 +182,29 @@ public class RecordIndexCache implements Closeable {
}
/**
- * Performs the actual cleaning to release memory for new caches.
+ * Removes evictable empty caches and releases memory for new caches when
needed.
*
* @param nextCacheSize the size for the next new cache
*/
private void cleanIfNecessary(long nextCacheSize) {
+ // Empty caches provide no lookup benefit, regardless of memory pressure.
+ caches.entrySet().removeIf(entry -> {
+ if (entry.getKey() < minRetainedCheckpointId &&
entry.getValue().isEmpty()) {
+ entry.getValue().close();
+ log.info("Clean record index cache for checkpoint: {}",
entry.getKey());
+ return true;
+ }
+ return false;
+ });
+
+ // Evict remaining caches oldest first, only under memory pressure.
+ long usedMemory = getInMemoryMapSize();
while (!caches.isEmpty() && caches.lastKey() < minRetainedCheckpointId
- && getInMemoryMapSize() + nextCacheSize > this.maxCacheSizeInBytes) {
- NavigableMap.Entry<Long, ExternalSpillableMap<String,
HoodieRecordGlobalLocation>> lastEntry = caches.pollLastEntry();
- lastEntry.getValue().close();
- log.info("Clean record index cache for checkpoint: {}",
lastEntry.getKey());
+ && usedMemory + nextCacheSize > maxCacheSizeInBytes) {
+ Map.Entry<Long, ExternalSpillableMap<String,
HoodieRecordGlobalLocation>> entry = caches.pollLastEntry();
+ usedMemory -= entry.getValue().getCurrentInMemoryMapSize();
+ entry.getValue().close();
+ log.info("Clean record index cache for checkpoint: {}", entry.getKey());
}
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordIndexCache.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordIndexCache.java
index d490a9c6ab96..f15444c015ff 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordIndexCache.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestRecordIndexCache.java
@@ -19,6 +19,8 @@
package org.apache.hudi.sink.partitioner.index;
import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
+import org.apache.hudi.common.serialization.DefaultSerializer;
+import org.apache.hudi.common.util.DefaultSizeEstimator;
import org.apache.hudi.common.util.collection.ExternalSpillableMap;
import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.utils.TestConfigurations;
@@ -194,6 +196,73 @@ public class TestRecordIndexCache {
assertTrue(cache.getCaches().containsKey(4L));
}
+ @Test
+ void testEmptyCheckpointsDoNotAccumulate() {
+ for (long checkpointId = 2L; checkpointId <= 1000L; checkpointId++) {
+ cache.addCheckpointCache(checkpointId);
+ cache.markAsEvictable(checkpointId);
+ assertEquals(2, cache.getCaches().size(), "Cleanup on checkpoint
creation retains the previous and current checkpoints");
+ assertTrue(cache.getCaches().containsKey(checkpointId - 1));
+ assertTrue(cache.getCaches().containsKey(checkpointId));
+ }
+ assertNull(cache.get("missing"));
+ HoodieRecordGlobalLocation location = new
HoodieRecordGlobalLocation("partition1", "1001", "file_id1");
+ cache.update("key", location);
+ assertEquals(location, cache.get("key"));
+ }
+
+ @Test
+ void testEmptyCheckpointCleanupPreservesNonEmptyAndInflightCaches() {
+ HoodieRecordGlobalLocation oldLocation = new
HoodieRecordGlobalLocation("partition1", "1001", "file_id1");
+ HoodieRecordGlobalLocation newLocation = new
HoodieRecordGlobalLocation("partition1", "1002", "file_id2");
+ cache.update("key", oldLocation);
+ cache.update("old_key", oldLocation);
+ cache.addCheckpointCache(2L);
+ ExternalSpillableMap<String, HoodieRecordGlobalLocation> emptyCache =
Mockito.spy(cache.getCaches().get(2L));
+ cache.getCaches().put(2L, emptyCache);
+ cache.addCheckpointCache(3L);
+ cache.update("key", newLocation);
+ cache.addCheckpointCache(4L);
+
+ // An older non-empty cache must not prevent cleanup of newer evictable
empty caches.
+ cache.markAsEvictable(3L);
+ cache.addCheckpointCache(5L);
+ assertEquals(4, cache.getCaches().size());
+ assertFalse(cache.getCaches().containsKey(2L));
+ Mockito.verify(emptyCache).close();
+ assertTrue(cache.getCaches().containsKey(4L), "Empty inflight checkpoints
must be retained");
+ assertTrue(cache.getCaches().containsKey(5L));
+ assertEquals(oldLocation, cache.get("old_key"));
+ assertEquals(newLocation, cache.get("key"));
+
+ cache.markAsEvictable(5L);
+ cache.addCheckpointCache(6L);
+ assertEquals(4, cache.getCaches().size());
+ assertFalse(cache.getCaches().containsKey(4L));
+ assertTrue(cache.getCaches().containsKey(5L), "The checkpoint at the
retention boundary must remain");
+ assertTrue(cache.getCaches().containsKey(6L));
+ assertEquals(oldLocation, cache.get("old_key"));
+ assertEquals(newLocation, cache.get("key"));
+ }
+
+ @Test
+ void testEmptyCheckpointCleanupPreservesDiskOnlyCache() throws IOException {
+ ExternalSpillableMap<String, HoodieRecordGlobalLocation> diskOnlyCache =
new ExternalSpillableMap<>(
+ 0L, tempDir.getAbsolutePath(), new DefaultSizeEstimator<>(), new
DefaultSizeEstimator<>(),
+ ExternalSpillableMap.DiskMapType.ROCKS_DB, new DefaultSerializer<>(),
false, "disk-only-cache");
+ cache.getCaches().put(1L, diskOnlyCache).close();
+ HoodieRecordGlobalLocation location = new
HoodieRecordGlobalLocation("partition1", "1001", "file_id1");
+ cache.update("key", location);
+ assertEquals(0L, diskOnlyCache.getCurrentInMemoryMapSize());
+ assertEquals(1, diskOnlyCache.getDiskBasedMapNumEntries());
+
+ cache.addCheckpointCache(2L);
+ cache.markAsEvictable(2L);
+ cache.addCheckpointCache(3L);
+ assertTrue(cache.getCaches().containsKey(1L), "A cache with spilled
records is not empty");
+ assertEquals(location, cache.get("key"));
+ }
+
@Test
void testClose() throws IOException {
cache.addCheckpointCache(2L);