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

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


The following commit(s) were added to refs/heads/master by this push:
     new 5d5184e0427 [FLINK-39998][state] Surface root cause exception when 
parallel RocksDB state download fails
5d5184e0427 is described below

commit 5d5184e0427a8c575e0bb745cac644ca54eb6963
Author: Raj Killamsetty <[email protected]>
AuthorDate: Fri Jun 26 16:04:35 2026 +0530

    [FLINK-39998][state] Surface root cause exception when parallel RocksDB 
state download fails
---
 .../state/rocksdb/RocksDBStateDownloader.java      |  55 +++++--
 .../state/rocksdb/RocksDBStateDownloaderTest.java  | 168 +++++++++++++++++++++
 2 files changed, 214 insertions(+), 9 deletions(-)

diff --git 
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBStateDownloader.java
 
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBStateDownloader.java
index c2af940af43..7e8e702a18d 100644
--- 
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBStateDownloader.java
+++ 
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBStateDownloader.java
@@ -28,7 +28,6 @@ import 
org.apache.flink.runtime.state.IncrementalKeyedStateHandle.HandleAndLocal
 import org.apache.flink.runtime.state.StreamStateHandle;
 import org.apache.flink.util.ExceptionUtils;
 import org.apache.flink.util.FileUtils;
-import org.apache.flink.util.FlinkRuntimeException;
 import org.apache.flink.util.IOUtils;
 import org.apache.flink.util.concurrent.FutureUtils;
 import org.apache.flink.util.function.ThrowingRunnable;
@@ -44,10 +43,13 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -84,6 +86,7 @@ public class RocksDBStateDownloader implements Closeable {
         CloseableRegistry internalCloser = new CloseableRegistry();
         // Make sure we also react to external close signals.
         closeableRegistry.registerCloseable(internalCloser);
+        AtomicReference<Throwable> rawException = new AtomicReference<>();
         try {
             // We have to wait for all futures to be completed, to make sure in
             // case of failure that we will clean up all the files
@@ -96,6 +99,12 @@ public class RocksDBStateDownloader implements Closeable {
                                                             runnable,
                                                             
transfer.getExecutorService()))
                                     .collect(Collectors.toList()));
+
+            // Capture the raw CompletionException before get() strips it. 
get() unwraps
+            // CompletionException to its cause (RuntimeException), losing the 
suppressed list
+            // that holds all parallel thread failures. whenComplete fires 
before get() unblocks.
+            downloadFuture.whenComplete((v, t) -> rawException.set(t));
+
             Exception interruptedException = null;
             while (!downloadFuture.isDone() || 
downloadFuture.isCompletedExceptionally()) {
                 try {
@@ -114,14 +123,8 @@ public class RocksDBStateDownloader implements Closeable {
                     .map(StateHandleDownloadSpec::getDownloadDestination)
                     .map(Path::toFile)
                     .forEach(FileUtils::deleteDirectoryQuietly);
-            // Error reporting
-            Throwable throwable = ExceptionUtils.stripExecutionException(e);
-            throwable = ExceptionUtils.stripException(throwable, 
RuntimeException.class);
-            if (throwable instanceof IOException) {
-                throw (IOException) throwable;
-            } else {
-                throw new FlinkRuntimeException("Failed to download data for 
state handles.", e);
-            }
+            Throwable raw = rawException.get();
+            throw buildDownloadException(raw != null ? raw : e);
         } finally {
             // Unregister and close the internal closer.
             if (closeableRegistry.unregisterCloseable(internalCloser)) {
@@ -261,6 +264,40 @@ public class RocksDBStateDownloader implements Closeable {
         }
     }
 
+    /**
+     * Builds a descriptive {@link IOException} from a potentially cascaded 
failure across multiple
+     * parallel download threads.
+     *
+     * <p>When one thread fails it closes the shared {@link 
CloseableRegistry}, causing all other
+     * threads to throw a {@code ClosedChannelException} on their local file 
writes. This method
+     * strips the wrapper chain of each collected failure to reach the real 
{@link IOException},
+     * deduplicates by type and message, and returns either the single unique 
cause directly or a
+     * merged exception listing all distinct failures.
+     */
+    private static IOException buildDownloadException(Throwable rawException) {
+        Map<String, Throwable> unique = new LinkedHashMap<>();
+        Stream.concat(Stream.of(rawException), 
Stream.of(rawException.getSuppressed()))
+                .map(t -> ExceptionUtils.stripException(t, 
CompletionException.class))
+                .map(t -> ExceptionUtils.stripException(t, 
RuntimeException.class))
+                .forEach(t -> unique.putIfAbsent(t.getClass().getName() + ":" 
+ t.getMessage(), t));
+
+        if (unique.size() == 1) {
+            Throwable t = unique.values().iterator().next();
+            return t instanceof IOException ? (IOException) t : new 
IOException(t);
+        }
+
+        String summary =
+                unique.values().stream()
+                        .map(t -> t.getClass().getSimpleName() + ": " + 
t.getMessage())
+                        .collect(Collectors.joining(" | "));
+        IOException merged =
+                new IOException(
+                        unique.size() + " downloads failed with distinct 
errors: [" + summary + "]",
+                        unique.values().iterator().next());
+        unique.values().stream().skip(1).forEach(merged::addSuppressed);
+        return merged;
+    }
+
     @Override
     public void close() throws IOException {
         this.transfer.close();
diff --git 
a/flink-state-backends/flink-statebackend-rocksdb/src/test/java/org/apache/flink/state/rocksdb/RocksDBStateDownloaderTest.java
 
b/flink-state-backends/flink-statebackend-rocksdb/src/test/java/org/apache/flink/state/rocksdb/RocksDBStateDownloaderTest.java
index c3c85cd271e..3f895d4370f 100644
--- 
a/flink-state-backends/flink-statebackend-rocksdb/src/test/java/org/apache/flink/state/rocksdb/RocksDBStateDownloaderTest.java
+++ 
b/flink-state-backends/flink-statebackend-rocksdb/src/test/java/org/apache/flink/state/rocksdb/RocksDBStateDownloaderTest.java
@@ -50,6 +50,7 @@ import java.util.Random;
 import java.util.UUID;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -230,11 +231,178 @@ public class RocksDBStateDownloaderTest extends 
TestLogger {
         Assert.assertFalse(closeableRegistry.isClosed());
     }
 
+    /**
+     * Tests that when a single download fails, the root cause exception is 
surfaced directly
+     * without being wrapped in a merged "N downloads failed" message.
+     */
+    @Test
+    public void testSingleDownloadFailureSurfacedDirectly() throws Exception {
+        IOException rootCause = new IOException("file not found on remote 
storage");
+        StreamStateHandle failingHandle = new ThrowingStateHandle(rootCause);
+
+        IncrementalRemoteKeyedStateHandle stateHandle =
+                new IncrementalRemoteKeyedStateHandle(
+                        UUID.randomUUID(),
+                        KeyGroupRange.EMPTY_KEY_GROUP_RANGE,
+                        1L,
+                        singletonList(HandleAndLocalPath.of(failingHandle, 
"state")),
+                        emptyList(),
+                        failingHandle);
+
+        try (RocksDBStateDownloader downloader = new 
RocksDBStateDownloader(1)) {
+            downloader.transferAllStateDataToDirectory(
+                    singletonList(
+                            new StateHandleDownloadSpec(
+                                    stateHandle, 
temporaryFolder.newFolder().toPath())),
+                    new CloseableRegistry());
+            fail("Expected IOException");
+        } catch (IOException e) {
+            assertEquals(rootCause, e.getCause());
+            Assert.assertFalse(
+                    "Single failure should not produce a merged message, got: 
" + e.getMessage(),
+                    e.getMessage() != null && 
e.getMessage().contains("downloads failed"));
+        }
+    }
+
+    /**
+     * Tests that when one download fails among many parallel ones, the root 
cause IOException is
+     * visible in the error rather than being buried under cascade 
ClosedChannelExceptions.
+     *
+     * <p>Before the fix, the error always showed ClosedChannelException (a 
cascade artifact) and
+     * the real cause (e.g. FileNotFoundException for a missing state file) 
was lost.
+     */
+    @Test
+    public void testRootCauseVisibleAmongCascadeFailures() throws Exception {
+        int numRemoteHandles = 3;
+        int numSubHandles = 6;
+        byte[][][] contents = createContents(numRemoteHandles, numSubHandles);
+        List<StateHandleDownloadSpec> downloadRequests = new 
ArrayList<>(numRemoteHandles);
+        for (int i = 0; i < numRemoteHandles; ++i) {
+            downloadRequests.add(
+                    createDownloadRequestForContent(
+                            temporaryFolder.newFolder().toPath(), contents[i], 
i));
+        }
+
+        IOException rootCause = new IOException("state file missing from 
remote storage");
+        downloadRequests
+                .get(0)
+                .getStateHandle()
+                .getSharedState()
+                .add(HandleAndLocalPath.of(new ThrowingStateHandle(rootCause), 
"error-handle"));
+
+        try (RocksDBStateDownloader downloader = new 
RocksDBStateDownloader(5)) {
+            downloader.transferAllStateDataToDirectory(downloadRequests, new 
CloseableRegistry());
+            fail("Expected IOException");
+        } catch (IOException e) {
+            boolean rootCauseVisible =
+                    (e.getCause() != null
+                                    && 
rootCause.getMessage().equals(e.getCause().getMessage()))
+                            || (e.getMessage() != null
+                                    && 
e.getMessage().contains(rootCause.getMessage()))
+                            || ExceptionUtils.findThrowable(
+                                            e, t -> 
rootCause.getMessage().equals(t.getMessage()))
+                                    .isPresent();
+            Assert.assertTrue(
+                    "Root cause '"
+                            + rootCause.getMessage()
+                            + "' should be visible in exception, got: "
+                            + e,
+                    rootCauseVisible);
+        }
+    }
+
+    /**
+     * Tests that when multiple downloads fail with distinct exceptions 
simultaneously, all distinct
+     * errors appear in the merged error message. A {@link CyclicBarrier} 
ensures all threads reach
+     * their failure point before any registry closure, so each failure is 
captured independently.
+     */
+    @Test
+    public void testMultipleDistinctFailuresMergedInMessage() throws Exception 
{
+        int n = 3;
+        CyclicBarrier barrier = new CyclicBarrier(n);
+        IOException causeA = new IOException("error-A: bucket not accessible");
+        IOException causeB = new IOException("error-B: file not found");
+        IOException causeC = new IOException("error-C: read timeout");
+
+        List<HandleAndLocalPath> handles = new ArrayList<>();
+        handles.add(HandleAndLocalPath.of(new 
BarrierThrowingStateHandle(barrier, causeA), "s1"));
+        handles.add(HandleAndLocalPath.of(new 
BarrierThrowingStateHandle(barrier, causeB), "s2"));
+        handles.add(HandleAndLocalPath.of(new 
BarrierThrowingStateHandle(barrier, causeC), "s3"));
+
+        IncrementalRemoteKeyedStateHandle stateHandle =
+                new IncrementalRemoteKeyedStateHandle(
+                        UUID.randomUUID(),
+                        KeyGroupRange.EMPTY_KEY_GROUP_RANGE,
+                        1L,
+                        handles,
+                        emptyList(),
+                        handles.get(0).getHandle());
+
+        try (RocksDBStateDownloader downloader = new 
RocksDBStateDownloader(n)) {
+            downloader.transferAllStateDataToDirectory(
+                    singletonList(
+                            new StateHandleDownloadSpec(
+                                    stateHandle, 
temporaryFolder.newFolder().toPath())),
+                    new CloseableRegistry());
+            fail("Expected IOException");
+        } catch (IOException e) {
+            Assert.assertTrue(
+                    "Expected merged error message, got: " + e.getMessage(),
+                    e.getMessage() != null
+                            && e.getMessage().contains("downloads failed with 
distinct errors"));
+            Assert.assertTrue(
+                    "Expected causeA in message", 
e.getMessage().contains(causeA.getMessage()));
+            Assert.assertTrue(
+                    "Expected causeB in message", 
e.getMessage().contains(causeB.getMessage()));
+            Assert.assertTrue(
+                    "Expected causeC in message", 
e.getMessage().contains(causeC.getMessage()));
+        }
+    }
+
     private void assertStateContentEqual(byte[] expected, Path path) throws 
IOException {
         byte[] actual = Files.readAllBytes(Paths.get(path.toUri()));
         assertArrayEquals(expected, actual);
     }
 
+    /**
+     * A {@link StreamStateHandle} that synchronizes all N threads at a {@link 
CyclicBarrier} before
+     * throwing, ensuring all failures happen before any registry closure.
+     */
+    private static class BarrierThrowingStateHandle implements 
TestStreamStateHandle {
+        private static final long serialVersionUID = 1L;
+
+        private final CyclicBarrier barrier;
+        private final IOException exception;
+
+        private BarrierThrowingStateHandle(CyclicBarrier barrier, IOException 
exception) {
+            this.barrier = barrier;
+            this.exception = exception;
+        }
+
+        @Override
+        public FSDataInputStream openInputStream() throws IOException {
+            try {
+                barrier.await(30, TimeUnit.SECONDS);
+            } catch (Exception e) {
+                throw new IOException("Barrier interrupted", e);
+            }
+            throw exception;
+        }
+
+        @Override
+        public Optional<byte[]> asBytesIfInMemory() {
+            return Optional.empty();
+        }
+
+        @Override
+        public void discardState() {}
+
+        @Override
+        public long getStateSize() {
+            return 0;
+        }
+    }
+
     private static class SpecifiedException extends IOException {
         SpecifiedException(String message) {
             super(message);

Reply via email to