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 1895de8a0648 perf(flink): preempt inactive write buckets on memory
exhaustion (#19728)
1895de8a0648 is described below
commit 1895de8a06480506793577563f29b93a46317206
Author: fhan <[email protected]>
AuthorDate: Fri Aug 28 10:11:00 2026 +0800
perf(flink): preempt inactive write buckets on memory exhaustion (#19728)
* perf(flink): preempt inactive write buckets on memory exhaustion
* refactor(flink): simplify preemptive memory reclamation
* refactor(flink): refine memory exhaustion recovery
---------
Co-authored-by: fhan <[email protected]>
---
.../org/apache/hudi/sink/StreamWriteFunction.java | 73 +++++----
.../sink/buffer/PreemptiveMemorySegmentPool.java | 135 ++++++++++++++++
.../TestBucketStreamWriteMemoryExhaustion.java | 178 +++++++++++++++------
.../buffer/TestPreemptiveMemorySegmentPool.java | 156 ++++++++++++++++++
4 files changed, 457 insertions(+), 85 deletions(-)
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteFunction.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteFunction.java
index cdce134c3c82..ddf6a3fd1be1 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteFunction.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteFunction.java
@@ -34,6 +34,7 @@ import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.configuration.OptionsResolver;
import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.metrics.FlinkStreamWriteMetrics;
+import org.apache.hudi.sink.buffer.PreemptiveMemorySegmentPool;
import org.apache.hudi.sink.buffer.RowDataBucket;
import org.apache.hudi.sink.buffer.TotalSizeTracer;
import org.apache.hudi.sink.bulk.RowDataKeyGen;
@@ -65,7 +66,6 @@ import org.apache.flink.table.runtime.util.MemorySegmentPool;
import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.util.Collector;
-import java.io.Closeable;
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
@@ -147,7 +147,7 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
*/
protected transient FlinkStreamWriteMetrics writeMetrics;
- protected transient MemorySegmentPool memorySegmentPool;
+ protected transient PreemptiveMemorySegmentPool preemptiveMemorySegmentPool;
protected transient RecordConverter recordConverter;
@@ -209,7 +209,9 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
private void initBuffer() {
this.buckets = new LinkedHashMap<>();
- this.memorySegmentPool =
this.memorySegmentPoolFactory.createMemorySegmentPool(config,
OptionsResolver.getWriteBufferSizeInBytes(config));
+ MemorySegmentPool delegate =
this.memorySegmentPoolFactory.createMemorySegmentPool(
+ config, OptionsResolver.getWriteBufferSizeInBytes(config));
+ this.preemptiveMemorySegmentPool = new
PreemptiveMemorySegmentPool(delegate, this::preemptMemory);
}
private void initRecordKeySort() {
@@ -322,7 +324,12 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
getBucketInfo(record),
this.config.get(FlinkOptions.WRITE_BATCH_SIZE)));
- return bucket.writeRow(record.getRowData());
+ this.preemptiveMemorySegmentPool.setCurrentOwner(bucketID);
+ try {
+ return bucket.writeRow(record.getRowData());
+ } finally {
+ this.preemptiveMemorySegmentPool.clearCurrentOwner();
+ }
} catch (MemoryPagesExhaustedException e) {
log.info("There are not enough free pages in the memory pool to create a
buffer; flushing is required first.");
return false;
@@ -379,45 +386,43 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
// A creation failure leaves no bucket in the map, while a write failure
leaves the
// diverged bucket in the map so that its committed records can be flushed
and disposed.
RowDataBucket failedBucket = this.buckets.get(bucketID);
- RowDataBucket bucketToFlush = this.buckets.values().stream()
- .filter(bucket -> !bucketID.equals(bucket.getBucketId()) &&
!bucket.isEmpty())
- .max(Comparator.comparingLong(RowDataBucket::getBufferSize))
- .orElse(null);
if (failedBucket == null) {
- if (bucketToFlush == null) {
+ if (!preemptMemory(bucketID)) {
throw new HoodieException(
"Not enough memory pages to create a RowData buffer and no
non-empty bucket can be flushed");
}
- flushAndDisposeBucket(bucketToFlush);
return;
}
ValidationUtils.checkState(
failedBucket.isDiverged(), "The failed RowData bucket has not
diverged");
+ // Allocation failures during writeRow have already tried to preempt
inactive buckets. The
+ // diverged bucket only needs to flush its committed rows and return its
own pages before retry.
+ flushAndDisposeBucket(failedBucket);
+ }
- RuntimeException failure = null;
- if (bucketToFlush != null) {
- try {
- flushAndDisposeBucket(bucketToFlush);
- } catch (RuntimeException e) {
- failure = e;
- }
- }
-
- try {
- flushAndDisposeBucket(failedBucket);
- } catch (RuntimeException e) {
- if (failure == null) {
- failure = e;
- } else {
- failure.addSuppressed(e);
- }
+ /**
+ * Flushes the largest non-empty bucket other than the excluded bucket to
return its pages to the
+ * shared memory pool.
+ *
+ * <p>The excluded bucket is either in the middle of serializing a row or is
about to retry buffer
+ * creation and must never be flushed here.
+ */
+ private boolean preemptMemory(String excludedBucketID) {
+ RowDataBucket bucketToFlush =
findLargestNonEmptyBucketExcluding(excludedBucketID);
+ if (bucketToFlush == null) {
+ return false;
}
+ flushAndDisposeBucket(bucketToFlush);
+ return true;
+ }
- if (failure != null) {
- throw failure;
- }
+ private RowDataBucket findLargestNonEmptyBucketExcluding(String
excludedBucketID) {
+ return this.buckets.values().stream()
+ .filter(bucket -> !excludedBucketID.equals(bucket.getBucketId()) &&
!bucket.isEmpty())
+ .max(Comparator.comparingLong(RowDataBucket::getBufferSize))
+ .orElse(null);
}
private void retryBufferRecord(
@@ -563,12 +568,12 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
private BinaryInMemorySortBuffer createDataBuffer() {
if (recordKeyComputer == null) {
- return BufferUtils.createBuffer(rowType, memorySegmentPool);
+ return BufferUtils.createBuffer(rowType, preemptiveMemorySegmentPool);
}
try {
return BufferUtils.createBuffer(
rowType,
- memorySegmentPool,
+ preemptiveMemorySegmentPool,
recordKeyComputer,
recordKeyComparator);
} catch (MemoryPagesExhaustedException e) {
@@ -621,8 +626,8 @@ public class StreamWriteFunction extends
AbstractStreamWriteFunction<HoodieFlink
}
try {
- if (this.memorySegmentPool instanceof Closeable) {
- ((Closeable) this.memorySegmentPool).close();
+ if (this.preemptiveMemorySegmentPool != null) {
+ this.preemptiveMemorySegmentPool.close();
}
} catch (Exception e) {
closeFailure = addCloseFailure(closeFailure, e);
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/buffer/PreemptiveMemorySegmentPool.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/buffer/PreemptiveMemorySegmentPool.java
new file mode 100644
index 000000000000..4fa21cf74dfe
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/buffer/PreemptiveMemorySegmentPool.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.sink.buffer;
+
+import org.apache.hudi.common.util.ValidationUtils;
+
+import org.apache.flink.core.memory.MemorySegment;
+import org.apache.flink.table.runtime.util.MemorySegmentPool;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * A {@link MemorySegmentPool} wrapper that can reclaim pages from an inactive
owner when the
+ * delegate pool is exhausted.
+ *
+ * <p>The owner represents the bucket whose buffer is currently requesting
pages. The reclaimer
+ * must never reclaim that owner because the request may occur in the middle
of serializing a row.
+ * If no other owner can release pages, this pool returns {@code null} and
lets the caller handle
+ * the allocation failure.
+ */
+public class PreemptiveMemorySegmentPool implements MemorySegmentPool,
Closeable {
+
+ /** Callback that reclaims memory from an owner other than the excluded
in-flight owner. */
+ @FunctionalInterface
+ public interface MemoryReclaimer {
+ /**
+ * Reclaims memory from an inactive owner.
+ *
+ * @param excludedOwnerId the owner currently requesting a page
+ * @return {@code true} if memory was reclaimed and allocation should be
retried
+ */
+ boolean reclaim(String excludedOwnerId);
+ }
+
+ private final MemorySegmentPool delegate;
+ private final MemoryReclaimer memoryReclaimer;
+
+ /**
+ * The owner whose buffer is currently serializing a row. This is {@code
null} outside
+ * {@code writeRow}, including while a new buffer is being created, so
allocation failures in
+ * those contexts are handled by the caller's existing fallback path.
+ */
+ @Nullable
+ private String currentOwnerId;
+
+ /** Prevents an allocation made from the reclamation callback from
recursively reclaiming. */
+ private boolean preempting;
+
+ public PreemptiveMemorySegmentPool(
+ MemorySegmentPool delegate,
+ MemoryReclaimer memoryReclaimer) {
+ ValidationUtils.checkArgument(delegate != null, "Delegate memory segment
pool must not be null");
+ ValidationUtils.checkArgument(memoryReclaimer != null, "Memory reclaimer
must not be null");
+ this.delegate = delegate;
+ this.memoryReclaimer = memoryReclaimer;
+ }
+
+ /** Marks the owner whose buffer is currently requesting memory pages. */
+ public void setCurrentOwner(String ownerId) {
+ ValidationUtils.checkArgument(ownerId != null, "Memory segment pool owner
must not be null");
+ ValidationUtils.checkState(
+ currentOwnerId == null,
+ "A memory segment pool owner is already active: " + currentOwnerId);
+ this.currentOwnerId = ownerId;
+ }
+
+ /** Clears the current owner after its buffer write finishes. */
+ public void clearCurrentOwner() {
+ this.currentOwnerId = null;
+ }
+
+ @Override
+ public int pageSize() {
+ return delegate.pageSize();
+ }
+
+ @Override
+ public void returnAll(List<MemorySegment> memorySegments) {
+ delegate.returnAll(memorySegments);
+ }
+
+ @Override
+ public int freePages() {
+ return delegate.freePages();
+ }
+
+ @Override
+ public MemorySegment nextSegment() {
+ MemorySegment segment = delegate.nextSegment();
+ // Reclamation requires an in-flight owner and is disabled while a
reclamation callback is
+ // running to prevent recursively selecting a victim that has not yet been
disposed.
+ if (segment != null || currentOwnerId == null || preempting) {
+ return segment;
+ }
+
+ preempting = true;
+ try {
+ if (!memoryReclaimer.reclaim(currentOwnerId)) {
+ return null;
+ }
+ // Retry this allocation once. A later page request may reclaim another
inactive owner,
+ // while this bounded retry prevents recursion if the callback does not
return any pages.
+ return delegate.nextSegment();
+ } finally {
+ preempting = false;
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (delegate instanceof Closeable) {
+ ((Closeable) delegate).close();
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bucket/TestBucketStreamWriteMemoryExhaustion.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bucket/TestBucketStreamWriteMemoryExhaustion.java
index 14d47b7bce5d..af44348215cd 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bucket/TestBucketStreamWriteMemoryExhaustion.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bucket/TestBucketStreamWriteMemoryExhaustion.java
@@ -20,6 +20,7 @@ package org.apache.hudi.sink.bucket;
import org.apache.hudi.client.WriteStatus;
import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.sink.StreamWriteFunction;
import org.apache.hudi.sink.buffer.RowDataBucket;
@@ -37,10 +38,12 @@ import org.apache.flink.table.data.StringData;
import org.apache.flink.table.data.TimestampData;
import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.logical.RowType;
-import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -55,56 +58,38 @@ class TestBucketStreamWriteMemoryExhaustion {
@TempDir
File tempFile;
- @Test
- void testRecoveryPreservesVariableLengthValuesAndReleasesPages() throws
Exception {
- DataType dataType = DataTypes.ROW(
- DataTypes.FIELD("uuid", DataTypes.VARCHAR(20)),
- DataTypes.FIELD("payload", DataTypes.VARCHAR(Integer.MAX_VALUE)),
- DataTypes.FIELD("attributes", DataTypes.MAP(DataTypes.VARCHAR(64),
DataTypes.VARCHAR(64))),
- DataTypes.FIELD("ts", DataTypes.TIMESTAMP(3)),
- DataTypes.FIELD("partition", DataTypes.VARCHAR(10)))
- .notNull();
+ @ParameterizedTest(name = "lsmTreeLayout={0}")
+ @ValueSource(booleans = {false, true})
+ void testRecoveryPreservesVariableLengthValuesAndReleasesPages(boolean
lsmTreeLayout) throws Exception {
+ DataType dataType = dataType();
RowType rowType = (RowType) dataType.getLogicalType();
- Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath(), dataType);
- conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.COPY_ON_WRITE.name());
- conf.set(FlinkOptions.OPERATION, "upsert");
- conf.set(FlinkOptions.INDEX_TYPE, "BUCKET");
- conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 4);
- // Leaves 1 MB for the shared RowData memory pool.
- conf.set(FlinkOptions.WRITE_TASK_MAX_SIZE, 201.0);
- conf.set(FlinkOptions.WRITE_MERGE_MAX_MEMORY, 100);
+ Configuration conf = writeConfig(dataType, 4, lsmTreeLayout);
Map<String, String> expectedPayloads = new HashMap<>();
Map<String, String> expectedAttributes = new HashMap<>();
- List<RowData> rows = createRows(rowType, expectedPayloads,
expectedAttributes);
+ List<RowData> rows = createRows(rowType, 40, expectedPayloads,
expectedAttributes);
TrackingBucketWriteFunctionWrapper pipeline =
new TrackingBucketWriteFunctionWrapper(tempFile.getAbsolutePath(),
conf);
pipeline.openFunction();
int initialFreePages = pipeline.freePages();
try {
- boolean reclaimedOtherBucketBeforeDivergedBucket = false;
+ boolean preemptedInactiveBucket = false;
for (RowData row : rows) {
int flushCountBeforeInvoke = pipeline.writeBatchCount();
pipeline.invoke(row);
List<FlushedBucket> invocationFlushes =
pipeline.flushesFrom(flushCountBeforeInvoke);
- for (int i = 1; i < invocationFlushes.size(); i++) {
- FlushedBucket previous = invocationFlushes.get(i - 1);
- FlushedBucket current = invocationFlushes.get(i);
- if (current.diverged && !previous.diverged) {
- assertTrue(
- !current.bucketId.equals(previous.bucketId),
- "memory reclamation should flush another bucket before the
diverged bucket");
- reclaimedOtherBucketBeforeDivergedBucket = true;
- }
+ if (!invocationFlushes.isEmpty()
+ && invocationFlushes.stream().noneMatch(flushedBucket ->
flushedBucket.diverged)) {
+ preemptedInactiveBucket = true;
}
}
int recoveryFlushCount = pipeline.writeBatchCount();
- assertTrue(recoveryFlushCount > 0, "the tight pool should trigger at
least one recovery flush");
+ assertTrue(recoveryFlushCount > 0, "the tight pool should trigger at
least one preemptive flush");
assertTrue(
- reclaimedOtherBucketBeforeDivergedBucket,
- "a write failure should reclaim another bucket before disposing the
diverged bucket");
+ preemptedInactiveBucket,
+ "memory exhaustion should flush an inactive bucket before the
current buffer diverges");
pipeline.checkpointFunction(1);
assertEquals(
@@ -115,31 +100,100 @@ class TestBucketStreamWriteMemoryExhaustion {
handleWriteEvents(pipeline, recoveryFlushCount + 1);
pipeline.checkpointComplete(1);
- List<GenericRecord> actualRecords = TestData.readAllData(tempFile,
rowType, 1);
- assertEquals(rows.size(), actualRecords.size(), "memory exhaustion
recovery must not lose or duplicate records");
- for (GenericRecord actualRecord : actualRecords) {
- String id = actualRecord.get("uuid").toString();
- assertTrue(
-
actualRecord.get("payload").toString().equals(expectedPayloads.get(id)),
- "variable-length payload should remain intact for " + id);
- Map<?, ?> actualAttributes = (Map<?, ?>)
actualRecord.get("attributes");
- assertEquals(1, actualAttributes.size());
- Map.Entry<?, ?> attribute =
actualAttributes.entrySet().iterator().next();
- assertEquals(
- expectedAttributes.get(id),
- attribute.getKey().toString() + "=" +
attribute.getValue().toString());
+ assertWrittenRows(rowType, rows.size(), expectedPayloads,
expectedAttributes);
+ } finally {
+ pipeline.close();
+ }
+ }
+
+ @ParameterizedTest(name = "lsmTreeLayout={0}")
+ @ValueSource(booleans = {false, true})
+ void testFlushesDivergedBucketWhenNoInactiveBucketCanBePreempted(boolean
lsmTreeLayout)
+ throws Exception {
+ DataType dataType = dataType();
+ RowType rowType = (RowType) dataType.getLogicalType();
+ // A single bucket guarantees that memory reclamation cannot find an
inactive victim while
+ // the current bucket is serializing a row.
+ Configuration conf = writeConfig(dataType, 1, lsmTreeLayout);
+
+ Map<String, String> expectedPayloads = new HashMap<>();
+ Map<String, String> expectedAttributes = new HashMap<>();
+ List<RowData> rows = createRows(rowType, 12, expectedPayloads,
expectedAttributes);
+
+ TrackingBucketWriteFunctionWrapper pipeline =
+ new TrackingBucketWriteFunctionWrapper(tempFile.getAbsolutePath(),
conf);
+ pipeline.openFunction();
+ int initialFreePages = pipeline.freePages();
+ try {
+ boolean flushedDivergedBucket = false;
+ for (RowData row : rows) {
+ int flushCountBeforeInvoke = pipeline.writeBatchCount();
+ pipeline.invoke(row);
+ List<FlushedBucket> invocationFlushes =
pipeline.flushesFrom(flushCountBeforeInvoke);
+ if (!invocationFlushes.isEmpty()) {
+ assertTrue(
+ invocationFlushes.stream().allMatch(flushedBucket ->
flushedBucket.diverged),
+ "without an inactive victim, recovery should only flush the
diverged current bucket");
+ flushedDivergedBucket = true;
+ }
}
+
+ int recoveryFlushCount = pipeline.writeBatchCount();
+ assertTrue(
+ flushedDivergedBucket,
+ "memory exhaustion should flush the diverged bucket when no inactive
victim exists");
+ pipeline.checkpointFunction(1);
+
+ assertEquals(
+ initialFreePages,
+ pipeline.freePages(),
+ "all pages should be returned after the checkpoint flush disposes
every bucket");
+
+ handleWriteEvents(pipeline, recoveryFlushCount + 1);
+ pipeline.checkpointComplete(1);
+
+ assertWrittenRows(rowType, rows.size(), expectedPayloads,
expectedAttributes);
} finally {
pipeline.close();
}
}
+ private static DataType dataType() {
+ return DataTypes.ROW(
+ DataTypes.FIELD("uuid", DataTypes.VARCHAR(20)),
+ DataTypes.FIELD("payload", DataTypes.VARCHAR(Integer.MAX_VALUE)),
+ DataTypes.FIELD("attributes", DataTypes.MAP(DataTypes.VARCHAR(64),
DataTypes.VARCHAR(64))),
+ DataTypes.FIELD("ts", DataTypes.TIMESTAMP(3)),
+ DataTypes.FIELD("partition", DataTypes.VARCHAR(10)))
+ .notNull();
+ }
+
+ private Configuration writeConfig(DataType dataType, int bucketCount,
boolean lsmTreeLayout) {
+ Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath(), dataType);
+ conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.COPY_ON_WRITE.name());
+ conf.set(FlinkOptions.OPERATION, "upsert");
+ conf.set(FlinkOptions.INDEX_TYPE, "BUCKET");
+ conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, bucketCount);
+ if (lsmTreeLayout) {
+ conf.setString(
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
+ }
+ // Leaves 1 MB for the shared RowData memory pool.
+ conf.set(FlinkOptions.WRITE_TASK_MAX_SIZE, 201.0);
+ conf.set(FlinkOptions.WRITE_MERGE_MAX_MEMORY, 100);
+ // Prevent batch-size based flushes so mid-invocation flushes come from
memory exhaustion.
+ conf.set(FlinkOptions.WRITE_BATCH_SIZE, 1024.0);
+ return conf;
+ }
+
private static List<RowData> createRows(
RowType rowType,
+ int rowCount,
Map<String, String> expectedPayloads,
Map<String, String> expectedAttributes) {
List<RowData> rows = new ArrayList<>();
- for (int i = 0; i < 40; i++) {
+ for (int i = 0; i < rowCount; i++) {
String id = "uuid-" + i;
String payload = "payload-" + i + "-" + repeat((char) ('a' + i % 26),
256 * 1024);
String attributeKey = "key-" + i;
@@ -160,6 +214,30 @@ class TestBucketStreamWriteMemoryExhaustion {
return rows;
}
+ private void assertWrittenRows(
+ RowType rowType,
+ int expectedRowCount,
+ Map<String, String> expectedPayloads,
+ Map<String, String> expectedAttributes) throws IOException {
+ List<GenericRecord> actualRecords = TestData.readAllData(tempFile,
rowType, 1);
+ assertEquals(
+ expectedRowCount,
+ actualRecords.size(),
+ "memory exhaustion recovery must not lose or duplicate records");
+ for (GenericRecord actualRecord : actualRecords) {
+ String id = actualRecord.get("uuid").toString();
+ assertTrue(
+
actualRecord.get("payload").toString().equals(expectedPayloads.get(id)),
+ "variable-length payload should remain intact for " + id);
+ Map<?, ?> actualAttributes = (Map<?, ?>) actualRecord.get("attributes");
+ assertEquals(1, actualAttributes.size());
+ Map.Entry<?, ?> attribute =
actualAttributes.entrySet().iterator().next();
+ assertEquals(
+ expectedAttributes.get(id),
+ attribute.getKey().toString() + "=" +
attribute.getValue().toString());
+ }
+ }
+
private static void handleWriteEvents(
TrackingBucketWriteFunctionWrapper pipeline, int eventCount) {
for (int i = 0; i < eventCount; i++) {
@@ -212,7 +290,7 @@ class TestBucketStreamWriteMemoryExhaustion {
}
int freePages() {
- return memorySegmentPool.freePages();
+ return preemptiveMemorySegmentPool.freePages();
}
int writeBatchCount() {
@@ -226,17 +304,15 @@ class TestBucketStreamWriteMemoryExhaustion {
@Override
protected List<WriteStatus> writeRecords(String instant, RowDataBucket
rowDataBucket) {
List<WriteStatus> writeStatuses = super.writeRecords(instant,
rowDataBucket);
- flushedBuckets.add(new FlushedBucket(rowDataBucket.getBucketId(),
rowDataBucket.isDiverged()));
+ flushedBuckets.add(new FlushedBucket(rowDataBucket.isDiverged()));
return writeStatuses;
}
}
private static class FlushedBucket {
- private final String bucketId;
private final boolean diverged;
- private FlushedBucket(String bucketId, boolean diverged) {
- this.bucketId = bucketId;
+ private FlushedBucket(boolean diverged) {
this.diverged = diverged;
}
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/buffer/TestPreemptiveMemorySegmentPool.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/buffer/TestPreemptiveMemorySegmentPool.java
new file mode 100644
index 000000000000..6ed2023749f3
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/buffer/TestPreemptiveMemorySegmentPool.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.sink.buffer;
+
+import org.apache.flink.core.memory.MemorySegment;
+import org.apache.flink.runtime.memory.MemoryManager;
+import org.apache.flink.table.runtime.util.LazyMemorySegmentPool;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Tests for {@link PreemptiveMemorySegmentPool}. */
+class TestPreemptiveMemorySegmentPool {
+
+ private static final int PAGE_SIZE = 32 * 1024;
+
+ @Test
+ void testReclaimsPageForCurrentOwner() {
+ HeapMemorySegmentPool delegate = new HeapMemorySegmentPool(PAGE_SIZE,
PAGE_SIZE);
+ AtomicReference<String> excludedOwner = new AtomicReference<>();
+ AtomicReference<PreemptiveMemorySegmentPool> poolReference = new
AtomicReference<>();
+ MemorySegment heldSegment = delegate.nextSegment();
+
+ PreemptiveMemorySegmentPool pool = new
PreemptiveMemorySegmentPool(delegate, ownerId -> {
+ excludedOwner.set(ownerId);
+ poolReference.get().returnAll(Collections.singletonList(heldSegment));
+ return true;
+ });
+ poolReference.set(pool);
+
+ pool.setCurrentOwner("bucket-0");
+ MemorySegment reclaimedSegment;
+ try {
+ reclaimedSegment = pool.nextSegment();
+ } finally {
+ pool.clearCurrentOwner();
+ }
+
+ assertEquals("bucket-0", excludedOwner.get());
+ assertSame(heldSegment, reclaimedSegment);
+ pool.returnAll(Collections.singletonList(reclaimedSegment));
+ assertEquals(1, pool.freePages());
+ }
+
+ @Test
+ void testDoesNotPreemptWithoutCurrentOwner() {
+ HeapMemorySegmentPool delegate = new HeapMemorySegmentPool(PAGE_SIZE,
PAGE_SIZE);
+ delegate.nextSegment();
+ AtomicInteger preemptionCount = new AtomicInteger();
+ PreemptiveMemorySegmentPool pool = new
PreemptiveMemorySegmentPool(delegate, ownerId -> {
+ preemptionCount.incrementAndGet();
+ return true;
+ });
+
+ assertNull(pool.nextSegment());
+ assertEquals(0, preemptionCount.get());
+ }
+
+ @Test
+ void testRetriesAllocationOnceWithoutNestedPreemption() {
+ HeapMemorySegmentPool delegate = new HeapMemorySegmentPool(PAGE_SIZE,
PAGE_SIZE);
+ delegate.nextSegment();
+ AtomicInteger preemptionCount = new AtomicInteger();
+ AtomicReference<PreemptiveMemorySegmentPool> poolReference = new
AtomicReference<>();
+ PreemptiveMemorySegmentPool pool = new
PreemptiveMemorySegmentPool(delegate, ownerId -> {
+ preemptionCount.incrementAndGet();
+ assertNull(poolReference.get().nextSegment(), "nested allocation must
not trigger preemption");
+ return true;
+ });
+ poolReference.set(pool);
+
+ pool.setCurrentOwner("bucket-0");
+ try {
+ assertNull(pool.nextSegment(), "allocation should fail when the callback
returns no pages");
+ } finally {
+ pool.clearCurrentOwner();
+ }
+ assertEquals(1, preemptionCount.get());
+ }
+
+ @Test
+ void testResetsPreemptionStateAfterCallbackFailure() {
+ HeapMemorySegmentPool delegate = new HeapMemorySegmentPool(PAGE_SIZE,
PAGE_SIZE);
+ delegate.nextSegment();
+ AtomicInteger preemptionCount = new AtomicInteger();
+ PreemptiveMemorySegmentPool pool = new
PreemptiveMemorySegmentPool(delegate, ownerId -> {
+ if (preemptionCount.incrementAndGet() == 1) {
+ throw new IllegalStateException("reclamation failed");
+ }
+ return false;
+ });
+
+ pool.setCurrentOwner("bucket-0");
+ try {
+ assertThrows(IllegalStateException.class, pool::nextSegment);
+ assertNull(pool.nextSegment(), "a later allocation should be allowed to
invoke the callback again");
+ } finally {
+ pool.clearCurrentOwner();
+ }
+ assertEquals(2, preemptionCount.get());
+ }
+
+ @Test
+ void testCloseReleasesManagedMemoryDelegate() throws Exception {
+ int numPages = 3;
+ MemoryManager memoryManager = MemoryManager.create((long) PAGE_SIZE *
numPages, PAGE_SIZE);
+ LazyMemorySegmentPool delegate =
+ new LazyMemorySegmentPool(new Object(), memoryManager, numPages);
+ PreemptiveMemorySegmentPool pool = new
PreemptiveMemorySegmentPool(delegate, ownerId -> false);
+ List<MemorySegment> allocatedSegments = new ArrayList<>();
+
+ try {
+ try {
+ for (int i = 0; i < numPages; i++) {
+ MemorySegment segment = pool.nextSegment();
+ assertNotNull(segment);
+ allocatedSegments.add(segment);
+ }
+ assertEquals(0, pool.freePages());
+ pool.returnAll(allocatedSegments);
+ } finally {
+ pool.close();
+ }
+ assertTrue(memoryManager.verifyEmpty(), "closing the wrapper should
release managed pages");
+ } finally {
+ memoryManager.shutdown();
+ }
+ }
+}