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 2d7be922ceb4 fix(flink): prevent write rate limiter stalls and thread
leaks (#20189)
2d7be922ceb4 is described below
commit 2d7be922ceb4bb671847b5abc687dc30ba0b8112
Author: fhan <[email protected]>
AuthorDate: Tue Oct 13 10:56:11 2026 +0800
fix(flink): prevent write rate limiter stalls and thread leaks (#20189)
* fix(flink): prevent write rate limiter stalls and thread leaks
---------
Co-authored-by: fhan <[email protected]>
---
.../org/apache/hudi/common/util/RateLimiter.java | 108 +++++++++++++++++---
.../apache/hudi/common/util/TestRatelimiter.java | 110 ++++++++++++++++++---
.../append/AppendWriteFunctionWithRateLimit.java | 26 +++--
.../RowDataToHoodieFunctionWithRateLimit.java | 26 +++--
.../hudi/sink/utils/FlinkRateLimiterFactory.java | 92 +++++++++++++++++
.../TestAppendWriteFunctionWithRateLimit.java | 50 ++++++++++
.../TestRowDataToHoodieFunctionWithRateLimit.java | 68 +++++++++++++
.../sink/utils/TestFlinkRateLimiterFactory.java | 80 +++++++++++++++
8 files changed, 522 insertions(+), 38 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java
b/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java
index ab31d5013022..978b6fa21951 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java
@@ -36,25 +36,67 @@ public class RateLimiter {
private final Semaphore semaphore;
private final int maxPermits;
+ private final long releasePermitsPeriod;
private final TimeUnit timePeriod;
+ private final Runnable permitWaitCallback;
+ private volatile boolean stopped;
private ScheduledExecutorService scheduler;
private static final long RELEASE_PERMITS_PERIOD_IN_SECONDS = 1L;
private static final long WAIT_BEFORE_NEXT_ACQUIRE_PERMIT_IN_MS = 5;
private static final int SCHEDULER_CORE_THREAD_POOL_SIZE = 1;
public static RateLimiter create(int permits, TimeUnit timePeriod) {
- final RateLimiter limiter = new RateLimiter(permits, timePeriod);
+ return create(permits, RELEASE_PERMITS_PERIOD_IN_SECONDS, timePeriod);
+ }
+
+ public static RateLimiter create(int permits, long releasePermitsPeriod,
TimeUnit timePeriod) {
+ validateConfiguration(permits, releasePermitsPeriod, timePeriod);
+ final RateLimiter limiter = new RateLimiter(permits, releasePermitsPeriod,
timePeriod);
+ limiter.releasePermitsPeriodically();
+ return limiter;
+ }
+
+ @VisibleForTesting
+ public static RateLimiter create(int permits, long releasePermitsPeriod,
TimeUnit timePeriod,
+ ScheduledExecutorService scheduler) {
+ validateConfiguration(permits, releasePermitsPeriod, timePeriod);
+ ValidationUtils.checkArgument(scheduler != null, "Scheduler must not be
null");
+ final RateLimiter limiter = new RateLimiter(permits, releasePermitsPeriod,
timePeriod);
+ limiter.releasePermitsPeriodically(scheduler);
+ return limiter;
+ }
+
+ @VisibleForTesting
+ static RateLimiter create(int permits, long releasePermitsPeriod, TimeUnit
timePeriod,
+ Runnable permitWaitCallback) {
+ validateConfiguration(permits, releasePermitsPeriod, timePeriod);
+ final RateLimiter limiter = new RateLimiter(
+ permits, releasePermitsPeriod, timePeriod, permitWaitCallback);
limiter.releasePermitsPeriodically();
return limiter;
}
- private RateLimiter(int permits, TimeUnit timePeriod) {
+ private static void validateConfiguration(int permits, long
releasePermitsPeriod, TimeUnit timePeriod) {
+ ValidationUtils.checkArgument(permits > 0, "Permits must be greater than
zero");
+ ValidationUtils.checkArgument(releasePermitsPeriod > 0, "Release permits
period must be greater than zero");
+ ValidationUtils.checkArgument(timePeriod != null, "Time period must not be
null");
+ }
+
+ private RateLimiter(int permits, long releasePermitsPeriod, TimeUnit
timePeriod) {
+ this(permits, releasePermitsPeriod, timePeriod, null);
+ }
+
+ private RateLimiter(int permits, long releasePermitsPeriod, TimeUnit
timePeriod,
+ Runnable permitWaitCallback) {
this.semaphore = new Semaphore(permits);
this.maxPermits = permits;
+ this.releasePermitsPeriod = releasePermitsPeriod;
this.timePeriod = timePeriod;
+ this.permitWaitCallback = permitWaitCallback;
}
public boolean tryAcquire(int numPermits) {
+ ValidationUtils.checkArgument(numPermits > 0, "Number of permits must be
greater than zero");
int remainingPermits = numPermits;
while (remainingPermits > 0) {
if (remainingPermits > maxPermits) {
@@ -68,27 +110,71 @@ public class RateLimiter {
}
public boolean acquire(int numOps) {
+ ValidationUtils.checkArgument(numOps > 0 && numOps <= maxPermits,
+ "Number of permits must be between one and the configured maximum");
+ return acquireInternal(numOps);
+ }
+
+ public boolean acquire() {
+ return acquireInternal(1);
+ }
+
+ private boolean acquireInternal(int numOps) {
try {
- while (!semaphore.tryAcquire(numOps)) {
+ while (!stopped && !semaphore.tryAcquire(numOps)) {
+ if (permitWaitCallback != null) {
+ permitWaitCallback.run();
+ }
Thread.sleep(WAIT_BEFORE_NEXT_ACQUIRE_PERMIT_IN_MS);
}
+ ValidationUtils.checkState(!stopped, "Rate limiter is stopped");
log.debug("acquire permits: {}, maxPermits: {}", numOps, maxPermits);
} catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
throw new RuntimeException("Unable to acquire permits", e);
}
return true;
}
- public void stop() {
- scheduler.shutdownNow();
+ public synchronized void stop() {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ }
+
+ public boolean isStopped() {
+ return stopped;
}
- public void releasePermitsPeriodically() {
- scheduler =
Executors.newScheduledThreadPool(SCHEDULER_CORE_THREAD_POOL_SIZE);
- scheduler.scheduleAtFixedRate(() -> {
- log.debug("Release permits: maxPermits: {}, available: {}", maxPermits,
semaphore.availablePermits());
- semaphore.release(maxPermits - semaphore.availablePermits());
- }, RELEASE_PERMITS_PERIOD_IN_SECONDS, RELEASE_PERMITS_PERIOD_IN_SECONDS,
timePeriod);
+ public synchronized void releasePermitsPeriodically() {
+ ValidationUtils.checkState(!stopped, "Cannot start a stopped rate
limiter");
+ if (scheduler != null) {
+ return;
+ }
+
releasePermitsPeriodically(Executors.newScheduledThreadPool(SCHEDULER_CORE_THREAD_POOL_SIZE,
+ new CustomizedThreadFactory("rate-limiter", true)));
+ }
+
+ private synchronized void
releasePermitsPeriodically(ScheduledExecutorService scheduler) {
+ ValidationUtils.checkState(!stopped, "Cannot start a stopped rate
limiter");
+ if (this.scheduler != null) {
+ return;
+ }
+ this.scheduler = scheduler;
+ try {
+ scheduler.scheduleAtFixedRate(() -> {
+ log.debug("Release permits: maxPermits: {}, available: {}",
maxPermits, semaphore.availablePermits());
+ semaphore.release(maxPermits - semaphore.availablePermits());
+ }, releasePermitsPeriod, releasePermitsPeriod, timePeriod);
+ } catch (RuntimeException e) {
+ this.scheduler = null;
+ scheduler.shutdownNow();
+ throw e;
+ }
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestRatelimiter.java
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestRatelimiter.java
index 3c27138bf2d5..d08f7f4fc039 100644
--- a/hudi-common/src/test/java/org/apache/hudi/common/util/TestRatelimiter.java
+++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestRatelimiter.java
@@ -19,11 +19,23 @@
package org.apache.hudi.common.util;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
/**
* Tests {@link RateLimiter}.
@@ -33,25 +45,93 @@ public class TestRatelimiter {
@Test
public void testRateLimiterWithNoThrottling() throws InterruptedException {
RateLimiter limiter = RateLimiter.create(1000, TimeUnit.SECONDS);
- long start = System.currentTimeMillis();
- assertEquals(true, limiter.tryAcquire(1000));
- // Sleep to represent some operation
- Thread.sleep(500);
- long end = System.currentTimeMillis();
- // With a large permit limit, there shouldn't be any throttling of
operations
- assertTrue((end - start) < TimeUnit.SECONDS.toMillis(2));
+ try {
+ long start = System.currentTimeMillis();
+ assertEquals(true, limiter.tryAcquire(1000));
+ // Sleep to represent some operation
+ Thread.sleep(500);
+ long end = System.currentTimeMillis();
+ // With a large permit limit, there shouldn't be any throttling of
operations
+ assertTrue((end - start) < TimeUnit.SECONDS.toMillis(2));
+ } finally {
+ limiter.stop();
+ }
}
@Test
public void testRateLimiterWithThrottling() throws InterruptedException {
RateLimiter limiter = RateLimiter.create(100, TimeUnit.SECONDS);
- long start = System.currentTimeMillis();
- assertEquals(true, limiter.tryAcquire(400));
- // Sleep to represent some operation
- Thread.sleep(500);
- long end = System.currentTimeMillis();
- // As size of operations is more than the maximum permits per second,
- // whole execution should be greater than 1 second
- assertTrue((end - start) >= TimeUnit.SECONDS.toMillis(2));
+ try {
+ long start = System.currentTimeMillis();
+ assertEquals(true, limiter.tryAcquire(400));
+ // Sleep to represent some operation
+ Thread.sleep(500);
+ long end = System.currentTimeMillis();
+ // As size of operations is more than the maximum permits per second,
+ // whole execution should be greater than 1 second
+ assertTrue((end - start) >= TimeUnit.SECONDS.toMillis(2));
+ } finally {
+ limiter.stop();
+ }
+ }
+
+ @Test
+ public void testCustomReleasePeriod() {
+ ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
+
+ RateLimiter limiter = RateLimiter.create(1, 100, TimeUnit.MILLISECONDS,
scheduler);
+ ArgumentCaptor<Runnable> refillTask =
ArgumentCaptor.forClass(Runnable.class);
+ try {
+ verify(scheduler).scheduleAtFixedRate(
+ refillTask.capture(), eq(100L), eq(100L), eq(TimeUnit.MILLISECONDS));
+ assertTrue(limiter.acquire());
+ refillTask.getValue().run();
+ assertTrue(limiter.acquire());
+ } finally {
+ limiter.stop();
+ }
+ verify(scheduler).shutdownNow();
+ }
+
+ @Test
+ public void testInvalidConfigurationIsRejected() {
+ assertThrows(IllegalArgumentException.class, () -> RateLimiter.create(0,
TimeUnit.SECONDS));
+ assertThrows(IllegalArgumentException.class, () -> RateLimiter.create(1,
0, TimeUnit.SECONDS));
+ assertThrows(IllegalArgumentException.class, () -> RateLimiter.create(1,
1, null));
+ }
+
+ @Test
+ public void testInvalidAcquireRequestIsRejected() {
+ RateLimiter limiter = RateLimiter.create(1, 1, TimeUnit.DAYS);
+ try {
+ assertThrows(IllegalArgumentException.class, () -> limiter.acquire(0));
+ assertThrows(IllegalArgumentException.class, () -> limiter.acquire(2));
+ } finally {
+ limiter.stop();
+ }
+ }
+
+ @Test
+ public void testStopIsIdempotentAndUnblocksAcquire() throws Exception {
+ CountDownLatch waitingForPermit = new CountDownLatch(1);
+ RateLimiter limiter = RateLimiter.create(1, 1, TimeUnit.DAYS,
waitingForPermit::countDown);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ assertTrue(limiter.acquire());
+ Future<Boolean> blockedAcquire = executor.submit(() ->
limiter.acquire());
+ assertTrue(waitingForPermit.await(1, TimeUnit.SECONDS));
+
+ limiter.stop();
+ limiter.stop();
+
+ ExecutionException exception = assertThrows(
+ ExecutionException.class, () -> blockedAcquire.get(1,
TimeUnit.SECONDS));
+ assertInstanceOf(IllegalStateException.class, exception.getCause());
+ assertTrue(limiter.isStopped());
+ } finally {
+ limiter.stop();
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS));
+ }
}
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithRateLimit.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithRateLimit.java
index 00d70a7b417f..977829d46937 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithRateLimit.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithRateLimit.java
@@ -20,6 +20,7 @@ package org.apache.hudi.sink.append;
import org.apache.hudi.common.util.RateLimiter;
import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.sink.utils.FlinkRateLimiterFactory;
import org.apache.hudi.utils.RuntimeContextUtils;
import org.apache.flink.configuration.Configuration;
@@ -27,8 +28,6 @@ import org.apache.flink.table.data.RowData;
import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.util.Collector;
-import java.util.concurrent.TimeUnit;
-
/**
* Append write function with configurable rate limit.
*/
@@ -37,7 +36,7 @@ public class AppendWriteFunctionWithRateLimit<I>
/**
* Total rate limit per second for this job.
*/
- private final double totalLimit;
+ private final long totalLimit;
/**
* Rate limit per second for per task.
@@ -52,13 +51,28 @@ public class AppendWriteFunctionWithRateLimit<I>
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
- this.rateLimiter =
- RateLimiter.create((int) totalLimit /
RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()),
TimeUnit.SECONDS);
+ this.rateLimiter = FlinkRateLimiterFactory.create(
+ totalLimit,
RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()));
}
@Override
public void processElement(I value, Context ctx, Collector<RowData> out)
throws Exception {
- rateLimiter.acquire(1);
+ rateLimiter.acquire();
super.processElement(value, ctx, out);
}
+
+ @Override
+ public void close() throws Exception {
+ try {
+ super.close();
+ } finally {
+ if (rateLimiter != null) {
+ rateLimiter.stop();
+ }
+ }
+ }
+
+ RateLimiter getRateLimiter() {
+ return rateLimiter;
+ }
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/transform/RowDataToHoodieFunctionWithRateLimit.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/transform/RowDataToHoodieFunctionWithRateLimit.java
index 2cd01da03a2d..673817c8c83e 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/transform/RowDataToHoodieFunctionWithRateLimit.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/transform/RowDataToHoodieFunctionWithRateLimit.java
@@ -21,14 +21,13 @@ package org.apache.hudi.sink.transform;
import org.apache.hudi.client.model.HoodieFlinkInternalRow;
import org.apache.hudi.common.util.RateLimiter;
import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.sink.utils.FlinkRateLimiterFactory;
import org.apache.hudi.utils.RuntimeContextUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.types.logical.RowType;
-import java.util.concurrent.TimeUnit;
-
/**
* Function that transforms RowData to a {@code HoodieFlinkInternalRow} with
RateLimit.
*/
@@ -37,7 +36,7 @@ public class RowDataToHoodieFunctionWithRateLimit<I extends
RowData, O extends H
/**
* Total rate limit per second for this job.
*/
- private final double totalLimit;
+ private final long totalLimit;
/**
* Rate limit per second for per task.
@@ -52,13 +51,28 @@ public class RowDataToHoodieFunctionWithRateLimit<I extends
RowData, O extends H
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
- this.rateLimiter =
- RateLimiter.create((int) totalLimit /
RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()),
TimeUnit.SECONDS);
+ this.rateLimiter = FlinkRateLimiterFactory.create(
+ totalLimit,
RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()));
}
@Override
public O map(I i) throws Exception {
- rateLimiter.acquire(1);
+ rateLimiter.acquire();
return super.map(i);
}
+
+ @Override
+ public void close() throws Exception {
+ try {
+ super.close();
+ } finally {
+ if (rateLimiter != null) {
+ rateLimiter.stop();
+ }
+ }
+ }
+
+ RateLimiter getRateLimiter() {
+ return rateLimiter;
+ }
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkRateLimiterFactory.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkRateLimiterFactory.java
new file mode 100644
index 000000000000..54ca23dbb8b4
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkRateLimiterFactory.java
@@ -0,0 +1,92 @@
+/*
+ * 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.utils;
+
+import org.apache.hudi.common.util.RateLimiter;
+import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Factory for distributing a job-wide write rate limit across Flink subtasks.
+ */
+public final class FlinkRateLimiterFactory {
+
+ private FlinkRateLimiterFactory() {
+ }
+
+ public static RateLimiter create(long totalRateLimit, int parallelism) {
+ RateLimitConfig rateLimitConfig = resolveRateLimit(totalRateLimit,
parallelism);
+ return RateLimiter.create(
+ rateLimitConfig.getPermits(), rateLimitConfig.getReleasePeriod(),
rateLimitConfig.getTimeUnit());
+ }
+
+ @VisibleForTesting
+ static RateLimiter create(long totalRateLimit, int parallelism,
ScheduledExecutorService scheduler) {
+ RateLimitConfig rateLimitConfig = resolveRateLimit(totalRateLimit,
parallelism);
+ return RateLimiter.create(
+ rateLimitConfig.getPermits(), rateLimitConfig.getReleasePeriod(),
rateLimitConfig.getTimeUnit(), scheduler);
+ }
+
+ static RateLimitConfig resolveRateLimit(long totalRateLimit, int
parallelism) {
+ ValidationUtils.checkArgument(totalRateLimit > 0, "Total rate limit must
be greater than zero");
+ ValidationUtils.checkArgument(parallelism > 0, "Parallelism must be
greater than zero");
+
+ if (totalRateLimit >= parallelism) {
+ long rateLimitPerSubtask = totalRateLimit / parallelism;
+ ValidationUtils.checkArgument(rateLimitPerSubtask <= Integer.MAX_VALUE,
+ "Rate limit per subtask exceeds the supported maximum");
+ return new RateLimitConfig((int) rateLimitPerSubtask, 1,
TimeUnit.SECONDS);
+ }
+
+ long periodNanos = divideRoundingUp(
+ Math.multiplyExact(TimeUnit.SECONDS.toNanos(1), parallelism),
totalRateLimit);
+ return new RateLimitConfig(1, periodNanos, TimeUnit.NANOSECONDS);
+ }
+
+ private static long divideRoundingUp(long dividend, long divisor) {
+ return 1 + (dividend - 1) / divisor;
+ }
+
+ static final class RateLimitConfig {
+ private final int permits;
+ private final long releasePeriod;
+ private final TimeUnit timeUnit;
+
+ private RateLimitConfig(int permits, long releasePeriod, TimeUnit
timeUnit) {
+ this.permits = permits;
+ this.releasePeriod = releasePeriod;
+ this.timeUnit = timeUnit;
+ }
+
+ int getPermits() {
+ return permits;
+ }
+
+ long getReleasePeriod() {
+ return releasePeriod;
+ }
+
+ TimeUnit getTimeUnit() {
+ return timeUnit;
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/append/TestAppendWriteFunctionWithRateLimit.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/append/TestAppendWriteFunctionWithRateLimit.java
new file mode 100644
index 000000000000..980e65a1ac76
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/append/TestAppendWriteFunctionWithRateLimit.java
@@ -0,0 +1,50 @@
+/*
+ * 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.append;
+
+import org.apache.hudi.common.util.RateLimiter;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.sink.utils.MockStreamingRuntimeContext;
+import org.apache.hudi.utils.TestConfigurations;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.data.RowData;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TestAppendWriteFunctionWithRateLimit {
+
+ @Test
+ void testCloseStopsRateLimiter() throws Exception {
+ Configuration config = new Configuration();
+ config.set(FlinkOptions.WRITE_RATE_LIMIT, 1L);
+ AppendWriteFunctionWithRateLimit<RowData> function =
+ new AppendWriteFunctionWithRateLimit<>(TestConfigurations.ROW_TYPE,
config);
+ function.setRuntimeContext(new MockStreamingRuntimeContext(false, 4, 0));
+ function.open(new Configuration());
+ RateLimiter limiter = function.getRateLimiter();
+
+ assertFalse(limiter.isStopped());
+ function.close();
+ function.close();
+ assertTrue(limiter.isStopped());
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/transform/TestRowDataToHoodieFunctionWithRateLimit.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/transform/TestRowDataToHoodieFunctionWithRateLimit.java
new file mode 100644
index 000000000000..6d86b0f28da8
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/transform/TestRowDataToHoodieFunctionWithRateLimit.java
@@ -0,0 +1,68 @@
+/*
+ * 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.transform;
+
+import org.apache.hudi.client.model.HoodieFlinkInternalRow;
+import org.apache.hudi.common.util.RateLimiter;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.sink.utils.MockStreamingRuntimeContext;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.types.logical.RowType;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TestRowDataToHoodieFunctionWithRateLimit {
+
+ @Test
+ void testLowRateLimitDoesNotBlockAndCloseStopsLimiter() throws Exception {
+ Configuration config = new Configuration();
+ config.set(FlinkOptions.WRITE_RATE_LIMIT, 1L);
+ config.set(FlinkOptions.RECORD_KEY_FIELD, "id");
+ config.set(FlinkOptions.PARTITION_PATH_FIELD, "partition");
+ RowType rowType = (RowType) DataTypes.ROW(
+ DataTypes.FIELD("id", DataTypes.STRING()),
+ DataTypes.FIELD("partition", DataTypes.STRING())).getLogicalType();
+ RowDataToHoodieFunctionWithRateLimit<RowData, HoodieFlinkInternalRow>
function =
+ new RowDataToHoodieFunctionWithRateLimit<>(rowType, config);
+ function.setRuntimeContext(new MockStreamingRuntimeContext(false, 4, 0));
+ function.open(new Configuration());
+ RateLimiter limiter = function.getRateLimiter();
+
+ try {
+ assertFalse(limiter.isStopped());
+ GenericRowData row = GenericRowData.of(
+ StringData.fromString("id-1"), StringData.fromString("p1"));
+ assertTimeoutPreemptively(Duration.ofSeconds(1), () ->
function.map(row));
+ } finally {
+ function.close();
+ }
+
+ assertTrue(limiter.isStopped());
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestFlinkRateLimiterFactory.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestFlinkRateLimiterFactory.java
new file mode 100644
index 000000000000..75d4573674e9
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestFlinkRateLimiterFactory.java
@@ -0,0 +1,80 @@
+/*
+ * 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.utils;
+
+import org.apache.hudi.common.util.RateLimiter;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class TestFlinkRateLimiterFactory {
+
+ @Test
+ void testRateLimitLowerThanParallelismUsesLongerReleasePeriod() {
+ ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
+ long expectedPeriodNanos = TimeUnit.SECONDS.toNanos(4);
+
+ RateLimiter limiter = FlinkRateLimiterFactory.create(1, 4, scheduler);
+ ArgumentCaptor<Runnable> refillTask =
ArgumentCaptor.forClass(Runnable.class);
+ try {
+ verify(scheduler).scheduleAtFixedRate(
+ refillTask.capture(), eq(expectedPeriodNanos),
eq(expectedPeriodNanos), eq(TimeUnit.NANOSECONDS));
+ assertTrue(limiter.acquire());
+ refillTask.getValue().run();
+ assertTrue(limiter.acquire());
+ } finally {
+ limiter.stop();
+ }
+ verify(scheduler).shutdownNow();
+ }
+
+ @Test
+ void testRateLimitAtLeastParallelismUsesPermitsPerSecond() {
+ FlinkRateLimiterFactory.RateLimitConfig config =
FlinkRateLimiterFactory.resolveRateLimit(8, 4);
+
+ assertEquals(2, config.getPermits());
+ assertEquals(1, config.getReleasePeriod());
+ assertEquals(TimeUnit.SECONDS, config.getTimeUnit());
+ }
+
+ @Test
+ void testFractionalRateLimitRoundsReleasePeriodUp() {
+ FlinkRateLimiterFactory.RateLimitConfig config =
FlinkRateLimiterFactory.resolveRateLimit(3, 4);
+
+ assertEquals(1, config.getPermits());
+ assertEquals(1_333_333_334L, config.getReleasePeriod());
+ assertEquals(TimeUnit.NANOSECONDS, config.getTimeUnit());
+ }
+
+ @Test
+ void testInvalidRateLimitInputsAreRejected() {
+ assertThrows(IllegalArgumentException.class, () ->
FlinkRateLimiterFactory.create(0, 4));
+ assertThrows(IllegalArgumentException.class, () ->
FlinkRateLimiterFactory.create(1, 0));
+ }
+}