This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ad0daa3962 [core] Quiesce manifest rewrite workers before cleanup
(#9376)
ad0daa3962 is described below
commit ad0daa39621a58e35f061cc95db43e3e8b688ca7
Author: QuakeWang <[email protected]>
AuthorDate: Wed Aug 26 10:32:26 2026 +0800
[core] Quiesce manifest rewrite workers before cleanup (#9376)
---
.../org/apache/paimon/utils/ThreadPoolUtils.java | 245 +++++++++++++++++++++
.../apache/paimon/utils/ThreadPoolUtilsTest.java | 238 ++++++++++++++++++++
.../paimon/operation/ManifestFileBlockMerger.java | 60 ++---
.../paimon/utils/ManifestReadThreadPool.java | 28 ++-
.../operation/ManifestRewriteCleanupTest.java | 181 +++++++++++++++
5 files changed, 718 insertions(+), 34 deletions(-)
diff --git
a/paimon-api/src/main/java/org/apache/paimon/utils/ThreadPoolUtils.java
b/paimon-api/src/main/java/org/apache/paimon/utils/ThreadPoolUtils.java
index 7ad3425367..b5c28a19a0 100644
--- a/paimon-api/src/main/java/org/apache/paimon/utils/ThreadPoolUtils.java
+++ b/paimon-api/src/main/java/org/apache/paimon/utils/ThreadPoolUtils.java
@@ -24,11 +24,13 @@ import
org.apache.paimon.shade.guava30.com.google.common.collect.Lists;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -45,6 +47,13 @@ import static
org.apache.paimon.utils.ThreadUtils.newDaemonThreadFactory;
/** Utils for thread pool. */
public class ThreadPoolUtils {
+ /** An iterator which waits for its active batch to quiesce when closed. */
+ public interface CloseableBatchIterator<T> extends Iterator<T>,
AutoCloseable {
+
+ @Override
+ void close();
+ }
+
/**
* Create a thread pool with max thread number. Inactive threads will
automatically exit.
*
@@ -124,6 +133,22 @@ public class ThreadPoolUtils {
};
}
+ /**
+ * Parallel processes one bounded batch at a time and returns results in
input order.
+ *
+ * <p>The caller must close the iterator to cancel unstarted tasks and
wait for running tasks.
+ */
+ public static <T, U> CloseableBatchIterator<T>
sequentialBatchedExecuteCloseable(
+ ExecutorService executor,
+ Function<U, List<T>> processor,
+ List<U> input,
+ int queueSize) {
+ if (queueSize <= 0) {
+ throw new NegativeArraySizeException("queue size should not be
negative");
+ }
+ return new SequentialBatchIterator<>(executor, processor, input,
queueSize);
+ }
+
public static <U> void randomlyOnlyExecute(
ExecutorService executor, Consumer<U> processor, Collection<U>
input) {
awaitAllFutures(submitAllTasks(executor, processor, input));
@@ -194,4 +219,224 @@ public class ThreadPoolUtils {
}
}
}
+
+ private static class SequentialBatchIterator<T, U> implements
CloseableBatchIterator<T> {
+
+ private final ExecutorService executor;
+ private final Function<U, List<T>> processor;
+ private final Queue<List<U>> batches;
+ private final Queue<BatchTask<T, U>> activeTasks = new ArrayDeque<>();
+
+ private Iterator<T> activeResults =
Collections.<T>emptyList().iterator();
+ private T next;
+ private boolean closed;
+
+ private SequentialBatchIterator(
+ ExecutorService executor,
+ Function<U, List<T>> processor,
+ List<U> input,
+ int queueSize) {
+ this.executor = executor;
+ this.processor = processor;
+ this.batches = new ArrayDeque<>(Lists.partition(input, queueSize));
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (!closed) {
+ advanceIfNeeded();
+ }
+ return next != null;
+ }
+
+ @Override
+ public T next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ T result = next;
+ next = null;
+ return result;
+ }
+
+ private void advanceIfNeeded() {
+ while (next == null) {
+ if (activeResults.hasNext()) {
+ next = activeResults.next();
+ } else if (!activeTasks.isEmpty()) {
+ BatchTask<T, U> task = activeTasks.peek();
+ try {
+ List<T> results = task.result();
+ activeTasks.poll();
+ activeResults = results.iterator();
+ } catch (RuntimeException | Error failure) {
+ if (task.failureReported()) {
+ activeTasks.poll();
+ }
+ throw failure;
+ }
+ } else if (batches.isEmpty()) {
+ return;
+ } else {
+ submitBatch(batches.poll());
+ }
+ }
+ }
+
+ private void submitBatch(List<U> batch) {
+ ClassLoader classLoader =
Thread.currentThread().getContextClassLoader();
+ for (U input : batch) {
+ BatchTask<T, U> task = new BatchTask<>(processor, input,
classLoader);
+ executor.execute(task);
+ activeTasks.add(task);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ batches.clear();
+
+ Throwable failure = null;
+ boolean interrupted = Thread.interrupted();
+ for (BatchTask<T, U> task : activeTasks) {
+ try {
+ task.cancel();
+ } catch (Throwable cleanupFailure) {
+ failure = firstOrSuppressed(cleanupFailure, failure);
+ }
+ }
+ for (BatchTask<T, U> task : activeTasks) {
+ while (true) {
+ try {
+ task.awaitCompletion();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ Throwable taskFailure = task.unreportedFailure();
+ if (taskFailure != null) {
+ failure = firstOrSuppressed(taskFailure, failure);
+ }
+ }
+ activeTasks.clear();
+ activeResults = Collections.<T>emptyList().iterator();
+ next = null;
+
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ if (failure != null) {
+ throw rethrow(failure);
+ }
+ }
+ }
+
+ private static class BatchTask<T, U> implements Runnable {
+
+ private static final int CREATED = 0;
+ private static final int RUNNING = 1;
+ private static final int CANCELLED = 2;
+ private static final int FINISHED = 3;
+
+ private final Function<U, List<T>> processor;
+ private final U input;
+ private final ClassLoader classLoader;
+ private final CountDownLatch completion = new CountDownLatch(1);
+
+ private int state = CREATED;
+ private Thread runner;
+ private List<T> result;
+ private Throwable failure;
+ private volatile boolean failureReported;
+
+ private BatchTask(Function<U, List<T>> processor, U input, ClassLoader
classLoader) {
+ this.processor = processor;
+ this.input = input;
+ this.classLoader = classLoader;
+ }
+
+ @Override
+ public void run() {
+ synchronized (this) {
+ if (state == CANCELLED) {
+ state = FINISHED;
+ completion.countDown();
+ return;
+ }
+ state = RUNNING;
+ runner = Thread.currentThread();
+ }
+
+ try {
+ Thread.currentThread().setContextClassLoader(classLoader);
+ result = processor.apply(input);
+ } catch (RuntimeException | Error taskFailure) {
+ failure = taskFailure;
+ } finally {
+ synchronized (this) {
+ runner = null;
+ state = FINISHED;
+ }
+ completion.countDown();
+ }
+ }
+
+ private synchronized void cancel() {
+ if (state == CREATED) {
+ state = CANCELLED;
+ completion.countDown();
+ } else if (state == RUNNING) {
+ runner.interrupt();
+ }
+ }
+
+ private List<T> result() {
+ try {
+ completion.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ if (failure != null) {
+ failureReported = true;
+ throw rethrow(failure);
+ }
+ return result;
+ }
+
+ private void awaitCompletion() throws InterruptedException {
+ completion.await();
+ }
+
+ private boolean failureReported() {
+ return failureReported;
+ }
+
+ private Throwable unreportedFailure() {
+ return failureReported ? null : failure;
+ }
+ }
+
+ private static Throwable firstOrSuppressed(Throwable newFailure, Throwable
previousFailure) {
+ if (previousFailure == null || previousFailure == newFailure) {
+ return newFailure;
+ }
+ previousFailure.addSuppressed(newFailure);
+ return previousFailure;
+ }
+
+ private static RuntimeException rethrow(Throwable failure) {
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ if (failure instanceof RuntimeException) {
+ return (RuntimeException) failure;
+ }
+ return new RuntimeException(failure);
+ }
}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/utils/ThreadPoolUtilsTest.java
b/paimon-api/src/test/java/org/apache/paimon/utils/ThreadPoolUtilsTest.java
new file mode 100644
index 0000000000..65a3331fc7
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/utils/ThreadPoolUtilsTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.paimon.utils;
+
+import org.apache.paimon.utils.ThreadPoolUtils.CloseableBatchIterator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+/** Tests for {@link ThreadPoolUtils}. */
+public class ThreadPoolUtilsTest {
+
+ @Test
+ public void testCloseableBatchReturnsInOrderAndBoundsSubmission() throws
Exception {
+ ThreadPoolExecutor workers = (ThreadPoolExecutor)
Executors.newFixedThreadPool(2);
+ ExecutorService consumer = Executors.newSingleThreadExecutor();
+ CountDownLatch firstStarted = new CountDownLatch(1);
+ CountDownLatch secondFinished = new CountDownLatch(1);
+ CountDownLatch releaseFirst = new CountDownLatch(1);
+ CloseableBatchIterator<Integer> iterator =
+ ThreadPoolUtils.sequentialBatchedExecuteCloseable(
+ workers,
+ input -> {
+ if (input == 0) {
+ firstStarted.countDown();
+ await(releaseFirst);
+ } else if (input == 1) {
+ secondFinished.countDown();
+ }
+ return Collections.singletonList(input);
+ },
+ Arrays.asList(0, 1, 2, 3),
+ 2);
+
+ try {
+ Future<Integer> firstResult =
+ consumer.submit(
+ () -> {
+ assertThat(iterator.hasNext()).isTrue();
+ return iterator.next();
+ });
+
+ assertThat(firstStarted.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(secondFinished.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(workers.getTaskCount()).isEqualTo(2);
+ assertThat(firstResult.isDone()).isFalse();
+
+ releaseFirst.countDown();
+ List<Integer> results = new ArrayList<>();
+ results.add(firstResult.get(3, TimeUnit.SECONDS));
+ assertThat(iterator.hasNext()).isTrue();
+ results.add(iterator.next());
+ assertThat(workers.getTaskCount()).isEqualTo(2);
+
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(workers.getTaskCount()).isEqualTo(4);
+ results.add(iterator.next());
+ assertThat(iterator.hasNext()).isTrue();
+ results.add(iterator.next());
+ assertThat(iterator.hasNext()).isFalse();
+ assertThat(results).containsExactly(0, 1, 2, 3);
+ } finally {
+ releaseFirst.countDown();
+ iterator.close();
+ consumer.shutdownNow();
+ workers.shutdownNow();
+ assertThat(consumer.awaitTermination(3,
TimeUnit.SECONDS)).isTrue();
+ assertThat(workers.awaitTermination(3, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ @Test
+ public void testCloseCancelsQueuedTasksAndWaitsUninterruptibly() throws
Exception {
+ ThreadPoolExecutor workers = (ThreadPoolExecutor)
Executors.newFixedThreadPool(1);
+ ExecutorService closer = Executors.newSingleThreadExecutor();
+ CountDownLatch secondStarted = new CountDownLatch(1);
+ CountDownLatch workerInterrupted = new CountDownLatch(1);
+ CountDownLatch allowSecondToExit = new CountDownLatch(1);
+ CountDownLatch closeStarted = new CountDownLatch(1);
+ AtomicInteger executions = new AtomicInteger();
+ AtomicBoolean thirdExecuted = new AtomicBoolean();
+ AtomicBoolean closeRestoredInterrupt = new AtomicBoolean();
+ AtomicReference<Thread> closeThread = new AtomicReference<>();
+ CloseableBatchIterator<Integer> iterator =
+ ThreadPoolUtils.sequentialBatchedExecuteCloseable(
+ workers,
+ input -> {
+ executions.incrementAndGet();
+ if (input == 1) {
+ secondStarted.countDown();
+ awaitIgnoringInterrupts(allowSecondToExit,
workerInterrupted);
+ } else if (input == 2) {
+ thirdExecuted.set(true);
+ }
+ return Collections.singletonList(input);
+ },
+ Arrays.asList(0, 1, 2),
+ 3);
+
+ try {
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isZero();
+ assertThat(secondStarted.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(workers.getTaskCount()).isEqualTo(3);
+
+ Future<?> closeResult =
+ closer.submit(
+ () -> {
+ closeThread.set(Thread.currentThread());
+ closeStarted.countDown();
+ iterator.close();
+
closeRestoredInterrupt.set(Thread.currentThread().isInterrupted());
+ });
+ assertThat(closeStarted.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(workerInterrupted.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(closeResult.isDone()).isFalse();
+
+ closeThread.get().interrupt();
+ allowSecondToExit.countDown();
+ closeResult.get(3, TimeUnit.SECONDS);
+
+ assertThat(closeRestoredInterrupt).isTrue();
+ assertThat(executions).hasValue(2);
+ assertThat(thirdExecuted).isFalse();
+ assertThat(iterator.hasNext()).isFalse();
+ iterator.close();
+ iterator.close();
+ assertThat(executions).hasValue(2);
+ } finally {
+ allowSecondToExit.countDown();
+ iterator.close();
+ closer.shutdownNow();
+ workers.shutdownNow();
+ assertThat(closer.awaitTermination(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(workers.awaitTermination(3, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ @Test
+ public void testClosePreservesPrimaryErrorAndSuppressesWorkerError()
throws Exception {
+ ThreadPoolExecutor workers = (ThreadPoolExecutor)
Executors.newFixedThreadPool(2);
+ CountDownLatch secondStarted = new CountDownLatch(1);
+ CountDownLatch waitForCancellation = new CountDownLatch(1);
+ AssertionError primaryFailure = new AssertionError("primary failure");
+ AssertionError workerFailure = new AssertionError("worker failure");
+ CloseableBatchIterator<Integer> iterator =
+ ThreadPoolUtils.sequentialBatchedExecuteCloseable(
+ workers,
+ input -> {
+ if (input == 0) {
+ await(secondStarted);
+ } else {
+ secondStarted.countDown();
+ try {
+ waitForCancellation.await();
+ } catch (InterruptedException e) {
+ throw workerFailure;
+ }
+ }
+ return Collections.singletonList(input);
+ },
+ Arrays.asList(0, 1),
+ 2);
+
+ try {
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isZero();
+
+ Throwable thrown =
+ catchThrowable(
+ () -> {
+ try (CloseableBatchIterator<Integer> ignored =
iterator) {
+ throw primaryFailure;
+ }
+ });
+
+
assertThat(thrown).isSameAs(primaryFailure).hasSuppressedException(workerFailure);
+ iterator.close();
+ } finally {
+ waitForCancellation.countDown();
+ iterator.close();
+ workers.shutdownNow();
+ assertThat(workers.awaitTermination(3, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void awaitIgnoringInterrupts(CountDownLatch latch,
CountDownLatch interrupted) {
+ while (true) {
+ try {
+ latch.await();
+ return;
+ } catch (InterruptedException e) {
+ interrupted.countDown();
+ }
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
index 05b6225f60..4f1bb922ab 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
@@ -42,6 +42,7 @@ import org.apache.paimon.stats.SimpleStatsConverter;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.CloseableIterator;
import org.apache.paimon.utils.Filter;
+import org.apache.paimon.utils.ThreadPoolUtils.CloseableBatchIterator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -60,6 +61,7 @@ import java.util.function.Function;
import static org.apache.paimon.manifest.ManifestFileMeta.allContainsRowId;
import static
org.apache.paimon.utils.ManifestReadThreadPool.sequentialBatchedExecute;
+import static
org.apache.paimon.utils.ManifestReadThreadPool.sequentialBatchedExecuteCloseable;
import static org.apache.paimon.utils.Preconditions.checkArgument;
import static org.apache.paimon.utils.Preconditions.checkState;
@@ -398,33 +400,37 @@ final class ManifestFileBlockMerger {
e);
}
};
- for (ManifestRewritePlan plan :
- sequentialBatchedExecute(planner, manifests,
manifestReadParallelism)) {
- if (fullCompaction
- && mustChange != null
- && !mustChange.test(plan.manifest)
- && plan.unchanged()) {
- checkState(
- unchangedManifests != null,
- "Full compaction requires an unchanged
manifest result.");
- unchangedManifests.add(plan.manifest);
- continue;
- }
- for (PlannedBlock block : plan.blocks) {
- if (block.compaction.canCopyEncodedBlock()) {
- writer.writeEncodedBlock(
- block.raw.encodedBlock(),
block.compaction.metadata);
- } else {
- writeBlockEntries(
- block.raw,
- writer,
- deletes,
- reusableIdentifier,
- fullCompaction,
- matchedEntries,
- emittedDeletes,
- metadata,
- plan.encodedRecordsCompatible);
+ try (CloseableBatchIterator<ManifestRewritePlan> plans =
+ sequentialBatchedExecuteCloseable(
+ planner, manifests, manifestReadParallelism)) {
+ while (plans.hasNext()) {
+ ManifestRewritePlan plan = plans.next();
+ if (fullCompaction
+ && mustChange != null
+ && !mustChange.test(plan.manifest)
+ && plan.unchanged()) {
+ checkState(
+ unchangedManifests != null,
+ "Full compaction requires an unchanged
manifest result.");
+ unchangedManifests.add(plan.manifest);
+ continue;
+ }
+ for (PlannedBlock block : plan.blocks) {
+ if (block.compaction.canCopyEncodedBlock()) {
+ writer.writeEncodedBlock(
+ block.raw.encodedBlock(),
block.compaction.metadata);
+ } else {
+ writeBlockEntries(
+ block.raw,
+ writer,
+ deletes,
+ reusableIdentifier,
+ fullCompaction,
+ matchedEntries,
+ emittedDeletes,
+ metadata,
+ plan.encodedRecordsCompatible);
+ }
}
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
b/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
index 7cf9778714..0ef818762d 100644
---
a/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
+++
b/paimon-core/src/main/java/org/apache/paimon/utils/ManifestReadThreadPool.java
@@ -57,19 +57,33 @@ public class ManifestReadThreadPool {
Function<U, List<T>> processor, List<U> input, @Nullable Integer
threadNum) {
threadNum = normalizeThreadNum(threadNum);
ExecutorService executor = getExecutorService(threadNum);
- if (threadNum == null) {
- threadNum =
- executor instanceof ThreadPoolExecutor
- ? ((ThreadPoolExecutor)
executor).getMaximumPoolSize()
- : ((SemaphoredDelegatingExecutor)
executor).getPermitCount();
- }
- return ThreadPoolUtils.sequentialBatchedExecute(executor, processor,
input, threadNum);
+ return ThreadPoolUtils.sequentialBatchedExecute(
+ executor, processor, input, effectiveThreadNum(threadNum,
executor));
+ }
+
+ /** This method parallel processes one bounded batch and waits for it when
closed. */
+ public static <T, U>
+ ThreadPoolUtils.CloseableBatchIterator<T>
sequentialBatchedExecuteCloseable(
+ Function<U, List<T>> processor, List<U> input, @Nullable
Integer threadNum) {
+ threadNum = normalizeThreadNum(threadNum);
+ ExecutorService executor = getExecutorService(threadNum);
+ return ThreadPoolUtils.sequentialBatchedExecuteCloseable(
+ executor, processor, input, effectiveThreadNum(threadNum,
executor));
}
private static @Nullable Integer normalizeThreadNum(@Nullable Integer
threadNum) {
return threadNum == null || threadNum <= 0 ? null : threadNum;
}
+ private static int effectiveThreadNum(@Nullable Integer threadNum,
ExecutorService executor) {
+ if (threadNum != null) {
+ return threadNum;
+ }
+ return executor instanceof ThreadPoolExecutor
+ ? ((ThreadPoolExecutor) executor).getMaximumPoolSize()
+ : ((SemaphoredDelegatingExecutor) executor).getPermitCount();
+ }
+
/** This method aims to parallel process tasks with randomly but return
values sequentially. */
public static <T, U> Iterator<T> randomlyExecuteSequentialReturn(
Function<U, List<T>> processor, List<U> input, @Nullable Integer
threadNum) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
index 658507da86..31c6940301 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestRewriteCleanupTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BinaryRowWriter;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.format.avro.AvroRawBlock;
import org.apache.paimon.fs.Path;
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.manifest.CollectedDeletes;
@@ -32,6 +33,7 @@ import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.FileSource;
import org.apache.paimon.manifest.ManifestAvroReader;
import org.apache.paimon.manifest.ManifestAvroWriter;
+import org.apache.paimon.manifest.ManifestAvroWriter.EncodedBlockMeta;
import org.apache.paimon.manifest.ManifestAvroWriter.EncodedEntry;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.manifest.ManifestFile;
@@ -44,6 +46,7 @@ import org.apache.paimon.stats.StatsTestUtils;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Filter;
import org.apache.paimon.utils.TraceableFileIO;
import org.junit.jupiter.api.BeforeEach;
@@ -53,6 +56,8 @@ import org.mockito.AdditionalAnswers;
import org.mockito.ArgumentMatchers;
import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.util.AbstractList;
import java.util.ArrayList;
@@ -60,6 +65,12 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
@@ -68,6 +79,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
/** Tests cleanup when manifest rewrites fail with {@link Error}. */
class ManifestRewriteCleanupTest extends ManifestFileMetaTestBase {
@@ -290,6 +302,112 @@ class ManifestRewriteCleanupTest extends
ManifestFileMetaTestBase {
assertNoManifestLeak(manifestCount);
}
+ @Test
+ void testParallelBlockRewriteQuiescesBeforeReleasingDeletes() throws
Exception {
+ ManifestFileMeta first = makeManifest(makeEntry(true, "first"));
+ ManifestFileMeta slow = makeManifest(makeEntry(true, "slow"));
+ List<ManifestFileMeta> input = Arrays.asList(first, slow);
+ int manifestCount = manifestFileCount();
+
+ AssertionError writerFailure = new AssertionError("block writer
failure");
+ RuntimeException planningFailure = new RuntimeException("planning
failure");
+ CountDownLatch slowWorkerStarted = new CountDownLatch(1);
+ CountDownLatch slowWorkerInterrupted = new CountDownLatch(1);
+ CountDownLatch allowSlowWorkerToExit = new CountDownLatch(1);
+ AtomicBoolean slowWorkerExited = new AtomicBoolean();
+ AtomicBoolean deletesReleased = new AtomicBoolean();
+ AtomicBoolean releaseObservedWorkerExit = new AtomicBoolean();
+
+ CollectedDeletes deletes = mock(CollectedDeletes.class);
+ when(deletes.isEmpty()).thenReturn(false);
+ when(deletes.useRowIdFilter()).thenReturn(false);
+ doAnswer(
+ invocation -> {
+ ProjectedManifestEntry entry =
invocation.getArgument(0);
+ if ("first".equals(entry.file().fileName())) {
+ await(slowWorkerStarted);
+ return true;
+ }
+ slowWorkerStarted.countDown();
+ try {
+ awaitIgnoringInterrupts(
+ allowSlowWorkerToExit,
slowWorkerInterrupted);
+ } finally {
+ slowWorkerExited.set(true);
+ }
+ throw planningFailure;
+ })
+ .when(deletes)
+ .copyable(
+ ArgumentMatchers.any(ProjectedManifestEntry.class),
+ ArgumentMatchers.any(ReusableIdentifier.class),
+ ArgumentMatchers.eq(false));
+ doAnswer(
+ invocation -> {
+ deletesReleased.set(true);
+
releaseObservedWorkerExit.set(slowWorkerExited.get());
+ return null;
+ })
+ .when(deletes)
+ .release();
+
+ ManifestFile spyManifestFile = spy(manifestFile);
+ ManifestAvroWriter activeWriter = spy(manifestFile.createAvroWriter());
+ doReturn(activeWriter).when(spyManifestFile).createAvroWriter();
+ doAnswer(
+ invocation -> {
+ activeWriter.write(makeEntry(true, "partial"));
+ throw writerFailure;
+ })
+ .when(activeWriter)
+ .writeEncodedBlock(
+ ArgumentMatchers.any(AvroRawBlock.class),
+ ArgumentMatchers.any(EncodedBlockMeta.class));
+ fileIO.failDeletes();
+
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+ Future<Throwable> rewriteResult =
+ caller.submit(
+ () -> {
+ try {
+ return catchThrowable(
+ () ->
+ invokeBlockRewrite(
+ input,
spyManifestFile, deletes, 2));
+ } finally {
+ deletes.release();
+ }
+ });
+
+ try {
+ assertThat(slowWorkerStarted.await(3, TimeUnit.SECONDS)).isTrue();
+ assertThat(slowWorkerInterrupted.await(3,
TimeUnit.SECONDS)).isTrue();
+ assertThat(slowWorkerExited).isFalse();
+ assertThat(deletesReleased).isFalse();
+ assertThat(rewriteResult.isDone()).isFalse();
+
+ allowSlowWorkerToExit.countDown();
+ Throwable thrown = rewriteResult.get(3, TimeUnit.SECONDS);
+
+ assertThat(thrown).isSameAs(writerFailure);
+ assertThat(slowWorkerExited).isTrue();
+ assertThat(deletesReleased).isTrue();
+ assertThat(releaseObservedWorkerExit).isTrue();
+ assertThat(thrown.getSuppressed())
+ .extracting(Throwable::getMessage)
+ .containsExactly(
+ "Failed to plan manifest rewrite for " +
slow.fileName(),
+ "delete failure 1");
+
assertThat(thrown.getSuppressed()[0].getCause()).isSameAs(planningFailure);
+ assertThat(fileIO.deleteAttempts()).isEqualTo(1);
+ assertNoManifestLeak(manifestCount);
+ } finally {
+ allowSlowWorkerToExit.countDown();
+ caller.shutdownNow();
+ assertThat(caller.awaitTermination(3, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
@Test
void testWriterPreservesWriteErrorAndCleansAllRollingFiles() throws
Exception {
ManifestAvroWriter writer = createManifestFile(1).createAvroWriter();
@@ -388,6 +506,69 @@ class ManifestRewriteCleanupTest extends
ManifestFileMetaTestBase {
assertNoManifestLeak(manifestCount);
}
+ private void invokeBlockRewrite(
+ List<ManifestFileMeta> input,
+ ManifestFile rewriteManifestFile,
+ CollectedDeletes deletes,
+ int parallelism)
+ throws Exception {
+ Method rewriteManifests =
+ ManifestFileBlockMerger.class.getDeclaredMethod(
+ "rewriteManifests",
+ List.class,
+ ManifestFile.class,
+ RowType.class,
+ CollectedDeletes.class,
+ boolean.class,
+ Filter.class,
+ List.class,
+ Integer.class);
+ rewriteManifests.setAccessible(true);
+ try {
+ rewriteManifests.invoke(
+ null,
+ input,
+ rewriteManifestFile,
+ PARTITION_TYPE,
+ deletes,
+ false,
+ null,
+ null,
+ parallelism);
+ } catch (InvocationTargetException e) {
+ Throwable failure = e.getCause();
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ if (failure instanceof Exception) {
+ throw (Exception) failure;
+ }
+ throw new RuntimeException(failure);
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ if (!latch.await(3, TimeUnit.SECONDS)) {
+ throw new AssertionError("Timed out waiting for the parallel
planning worker.");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void awaitIgnoringInterrupts(CountDownLatch latch,
CountDownLatch interrupted) {
+ while (true) {
+ try {
+ latch.await();
+ return;
+ } catch (InterruptedException e) {
+ interrupted.countDown();
+ }
+ }
+ }
+
private ManifestEntryRunMergePlan runMergePlan(
ManifestFileMeta input,
CollectedDeletes deletes,