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

RussellSpitzer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 1fbd13e42b Core: Block on unfinished futures in Tasks.waitFor instead 
of polling (#18540)
1fbd13e42b is described below

commit 1fbd13e42b285359f62828ee2743f78537006ba0
Author: Raunaq Morarka <[email protected]>
AuthorDate: Thu Oct 29 22:36:32 2026 +0530

    Core: Block on unfinished futures in Tasks.waitFor instead of polling 
(#18540)
---
 .../main/java/org/apache/iceberg/util/Tasks.java   |  90 ++++++-------
 .../org/apache/iceberg/TestRemoveSnapshots.java    |  12 +-
 .../java/org/apache/iceberg/util/TestTasks.java    | 140 +++++++++++++++++++++
 3 files changed, 184 insertions(+), 58 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/util/Tasks.java 
b/core/src/main/java/org/apache/iceberg/util/Tasks.java
index df28528ba3..e167662ae0 100644
--- a/core/src/main/java/org/apache/iceberg/util/Tasks.java
+++ b/core/src/main/java/org/apache/iceberg/util/Tasks.java
@@ -503,67 +503,51 @@ public class Tasks {
     return false;
   }
 
-  @SuppressWarnings("checkstyle:CyclomaticComplexity")
   private static Collection<Throwable> waitFor(Collection<Future<?>> futures) {
-    while (true) {
-      int numFinished = 0;
+    List<Throwable> uncaught = Lists.newArrayList();
+    Error error = null;
+    try {
+      // block on each future in turn and collect any uncaught exceptions
       for (Future<?> future : futures) {
-        if (future.isDone()) {
-          numFinished += 1;
-        }
-      }
-
-      if (numFinished == futures.size()) {
-        List<Throwable> uncaught = Lists.newArrayList();
-        // all of the futures are done, get any uncaught exceptions
-        for (Future<?> future : futures) {
-          try {
-            future.get();
-
-          } catch (InterruptedException e) {
-            LOG.warn("Interrupted while getting future results", e);
-            for (Throwable t : uncaught) {
-              e.addSuppressed(t);
-            }
-            Thread.currentThread().interrupt();
-            throw new RuntimeException(e);
-
-          } catch (CancellationException e) {
-            // ignore cancellations
-
-          } catch (ExecutionException e) {
-            Throwable cause = e.getCause();
-            if (cause instanceof Error) {
-              for (Throwable t : uncaught) {
-                cause.addSuppressed(t);
-              }
-              throw (Error) cause;
-            }
-
-            if (cause != null) {
-              uncaught.add(e);
-            }
-
-            LOG.warn("Task threw uncaught exception", cause);
+        try {
+          future.get();
+
+        } catch (CancellationException e) {
+          // ignore cancellations
+
+        } catch (ExecutionException e) {
+          Throwable cause = e.getCause();
+          if (cause instanceof Error && error == null) {
+            // thrown once all futures are done
+            error = (Error) cause;
+          } else if (cause != null) {
+            uncaught.add(e);
           }
-        }
 
-        return uncaught;
+          LOG.warn("Task threw uncaught exception", cause);
+        }
+      }
+    } catch (InterruptedException e) {
+      LOG.warn("Interrupted while waiting for tasks to finish", e);
+      for (Throwable t : uncaught) {
+        e.addSuppressed(t);
+      }
 
-      } else {
-        try {
-          Thread.sleep(10);
-        } catch (InterruptedException e) {
-          LOG.warn("Interrupted while waiting for tasks to finish", e);
+      for (Future<?> future : futures) {
+        future.cancel(true);
+      }
+      Thread.currentThread().interrupt();
+      throw new RuntimeException(e);
+    }
 
-          for (Future<?> future : futures) {
-            future.cancel(true);
-          }
-          Thread.currentThread().interrupt();
-          throw new RuntimeException(e);
-        }
+    if (error != null) {
+      for (Throwable t : uncaught) {
+        error.addSuppressed(t);
       }
+      throw error;
     }
+
+    return uncaught;
   }
 
   /** A range, [ 0, size ) */
diff --git a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java 
b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
index e3dd980dfe..0348972241 100644
--- a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
+++ b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
@@ -1671,7 +1671,9 @@ public class TestRemoveSnapshots extends TestBase {
 
     long lastSnapshotId = tableWithBulkIO.currentSnapshot().snapshotId();
 
-    
removeSnapshots(tableWithBulkIO).expireOlderThan(System.currentTimeMillis()).commit();
+    removeSnapshots(tableWithBulkIO)
+        .expireOlderThan(tableWithBulkIO.currentSnapshot().timestampMillis() + 
1)
+        .commit();
 
     
assertThat(tableWithBulkIO.currentSnapshot().snapshotId()).isEqualTo(lastSnapshotId);
     
assertThat(tableWithBulkIO.snapshots()).containsOnly(tableWithBulkIO.currentSnapshot());
@@ -1720,7 +1722,7 @@ public class TestRemoveSnapshots extends TestBase {
     Set<String> deletedFiles = Sets.newHashSet();
     // Expiring snapshots should remove the data_bucket partition
     removeSnapshots(table)
-        .expireOlderThan(System.currentTimeMillis())
+        .expireOlderThan(table.currentSnapshot().timestampMillis() + 1)
         .cleanExpiredMetadata(true)
         .deleteWith(deletedFiles::add)
         .commit();
@@ -1755,7 +1757,7 @@ public class TestRemoveSnapshots extends TestBase {
     Set<String> deletedFiles = Sets.newHashSet();
     // Expiring snapshots should remove only the unpartitioned spec
     removeSnapshots(table)
-        .expireOlderThan(System.currentTimeMillis())
+        .expireOlderThan(table.currentSnapshot().timestampMillis() + 1)
         .cleanExpiredMetadata(true)
         .deleteWith(deletedFiles::add)
         .commit();
@@ -1787,7 +1789,7 @@ public class TestRemoveSnapshots extends TestBase {
     Set<String> deletedFiles = Sets.newHashSet();
     // Expire all snapshots and schemas except the current ones.
     removeSnapshots(table)
-        .expireOlderThan(System.currentTimeMillis())
+        .expireOlderThan(table.currentSnapshot().timestampMillis() + 1)
         .cleanExpiredMetadata(true)
         .deleteWith(deletedFiles::add)
         .commit();
@@ -1821,7 +1823,7 @@ public class TestRemoveSnapshots extends TestBase {
     Set<String> deletedFiles = Sets.newHashSet();
     // Expire all snapshots except the current one. No unused schemas or specs 
to be removed.
     removeSnapshots(table)
-        .expireOlderThan(System.currentTimeMillis())
+        .expireOlderThan(table.currentSnapshot().timestampMillis() + 1)
         .cleanExpiredMetadata(true)
         .deleteWith(deletedFiles::add)
         .commit();
diff --git a/core/src/test/java/org/apache/iceberg/util/TestTasks.java 
b/core/src/test/java/org/apache/iceberg/util/TestTasks.java
index c46f5a2d6e..3263b776a7 100644
--- a/core/src/test/java/org/apache/iceberg/util/TestTasks.java
+++ b/core/src/test/java/org/apache/iceberg/util/TestTasks.java
@@ -26,21 +26,44 @@ import java.io.InterruptedIOException;
 import java.io.UncheckedIOException;
 import java.net.SocketTimeoutException;
 import java.nio.channels.ClosedByInterruptException;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 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.FutureTask;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RunnableFuture;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
 import org.apache.iceberg.metrics.Counter;
 import org.apache.iceberg.metrics.DefaultMetricsContext;
+import 
org.apache.iceberg.relocated.com.google.common.util.concurrent.Uninterruptibles;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
 public class TestTasks {
 
+  private RecordingExecutor executor;
+
+  @BeforeEach
+  void before() {
+    executor = new RecordingExecutor(2);
+  }
+
+  @AfterEach
+  void after() {
+    executor.shutdownNow();
+  }
+
   @Test
   public void attemptCounterIsIncreasedOnRetries() {
     Counter counter = new DefaultMetricsContext().counter("counter");
@@ -197,4 +220,121 @@ public class TestTasks {
       callerPool.shutdownNow();
     }
   }
+
+  @Test
+  void parallelRunRethrowsTaskErrorAfterOtherTasksFinish() {
+    AtomicBoolean otherTaskFinished = new AtomicBoolean();
+
+    assertThatThrownBy(
+            () ->
+                Tasks.range(2)
+                    .executeWith(executor)
+                    .run(
+                        x -> {
+                          if (x == 0) {
+                            throw new Error("task failed");
+                          }
+                          executor.awaitBlockedGet(1);
+                          otherTaskFinished.set(true);
+                        }))
+        .isExactlyInstanceOf(Error.class)
+        .hasMessage("task failed");
+
+    assertThat(otherTaskFinished).isTrue();
+  }
+
+  @Test
+  void parallelRunIgnoresCancelledFuture() throws InterruptedException {
+    CountDownLatch release = new CountDownLatch(1);
+
+    boolean succeeded =
+        Tasks.range(2)
+            .executeWith(executor)
+            .run(
+                x -> {
+                  if (x == 0) {
+                    release.await(10, TimeUnit.SECONDS);
+                  } else {
+                    executor.awaitBlockedGet(0);
+                    executor.futures().get(0).cancel(false);
+                    release.countDown();
+                  }
+                },
+                InterruptedException.class);
+
+    assertThat(succeeded).isTrue();
+    assertThat(executor.futures().get(0).isCancelled()).isTrue();
+  }
+
+  @Test
+  void parallelRunCancelsFuturesOnInterrupt() {
+    Thread waitingThread = Thread.currentThread();
+    CountDownLatch neverReleased = new CountDownLatch(1);
+
+    assertThatThrownBy(
+            () ->
+                Tasks.range(4)
+                    .executeWith(executor)
+                    .run(
+                        x -> {
+                          if (x == 0) {
+                            executor.awaitBlockedGet(0);
+                            waitingThread.interrupt();
+                          }
+                          neverReleased.await(10, TimeUnit.SECONDS);
+                        },
+                        InterruptedException.class))
+        .isInstanceOf(RuntimeException.class)
+        .hasMessageContaining("InterruptedException")
+        .hasCauseInstanceOf(InterruptedException.class);
+
+    assertThat(Thread.interrupted()).isTrue();
+    assertThat(executor.futures()).hasSize(4).allMatch(Future::isCancelled);
+  }
+
+  private static class RecordingExecutor extends ThreadPoolExecutor {
+    private final List<RecordingFuture<?>> futures = new 
CopyOnWriteArrayList<>();
+
+    RecordingExecutor(int threads) {
+      super(threads, threads, 0L, TimeUnit.MILLISECONDS, new 
LinkedBlockingQueue<>());
+    }
+
+    List<RecordingFuture<?>> futures() {
+      return futures;
+    }
+
+    void awaitBlockedGet(int index) {
+      futures.get(index).awaitBlockedGet();
+    }
+
+    @Override
+    protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
+      RecordingFuture<T> future = new RecordingFuture<>(runnable, value);
+      futures.add(future);
+      return future;
+    }
+  }
+
+  private static class RecordingFuture<T> extends FutureTask<T> {
+    // counts down when get() is called before the task is done
+    private final CountDownLatch blockedGet = new CountDownLatch(1);
+
+    RecordingFuture(Runnable runnable, T value) {
+      super(runnable, value);
+    }
+
+    void awaitBlockedGet() {
+      assertThat(Uninterruptibles.awaitUninterruptibly(blockedGet, 10, 
TimeUnit.SECONDS))
+          .as("get() should be called before the task is done")
+          .isTrue();
+    }
+
+    @Override
+    public T get() throws InterruptedException, ExecutionException {
+      if (!isDone()) {
+        blockedGet.countDown();
+      }
+      return super.get();
+    }
+  }
 }

Reply via email to