| // Copyright 2026 The Bazel Authors. All rights reserved. |
| // |
| // Licensed 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 com.google.devtools.build.lib.concurrent; |
| |
| import static com.google.common.truth.Truth.assertThat; |
| import static com.google.common.util.concurrent.Futures.immediateFuture; |
| import static com.google.devtools.build.lib.concurrent.safeexecutor.SafeExecutor.safeDirectExecutor; |
| import static java.util.concurrent.ForkJoinPool.commonPool; |
| import static org.junit.Assert.assertThrows; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.util.concurrent.FutureCallback; |
| import com.google.common.util.concurrent.ListenableFuture; |
| import com.google.common.util.concurrent.SettableFuture; |
| import com.google.devtools.build.lib.concurrent.safeexecutor.RejectionHandlingRunnable; |
| import com.google.devtools.build.lib.concurrent.safeexecutor.SafeExecutor; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.concurrent.CancellationException; |
| import java.util.concurrent.ConcurrentLinkedQueue; |
| import java.util.concurrent.CountDownLatch; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.RejectedExecutionException; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicReference; |
| import javax.annotation.Nullable; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| @RunWith(JUnit4.class) |
| public final class AccumulatingQuiescingFutureTest { |
| |
| @Test |
| public void accumulateFutureResult_success_foldsResultAndDecrementsCount() throws Exception { |
| var future = new AccumulatingStringFuture(); |
| var subtask1 = SettableFuture.<String>create(); |
| var subtask2 = SettableFuture.<String>create(); |
| |
| future.addFuture(subtask1, safeDirectExecutor()); |
| future.addFuture(subtask2, safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| assertThat(future.isDone()).isFalse(); |
| |
| subtask1.set("hello"); |
| assertThat(future.isDone()).isFalse(); |
| |
| subtask2.set("world"); |
| assertThat(future.isDone()).isTrue(); |
| assertThat(future.get()).containsExactly("hello", "world").inOrder(); |
| } |
| |
| @Test |
| public void accumulateFutureResult_throwsException_recordsExceptionAndDecrementsCount() |
| throws Exception { |
| AtomicBoolean doneWithErrorCalled = new AtomicBoolean(false); |
| var expectedError = new RuntimeException("failed during accumulateFutureResult"); |
| |
| var future = |
| new AccumulatingQuiescingFuture<String, String>(safeDirectExecutor()) { |
| @Override |
| protected void accumulateFutureResult(String result) { |
| throw expectedError; |
| } |
| |
| @Override |
| protected String getValue() { |
| return "unused"; |
| } |
| |
| @Override |
| protected void doneWithError( |
| @Nullable Throwable primaryCause, ImmutableList<Throwable> secondaryCauses) { |
| doneWithErrorCalled.set(true); |
| } |
| }; |
| |
| var subtask = SettableFuture.<String>create(); |
| future.addFuture(subtask, safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| subtask.set("trigger"); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(doneWithErrorCalled.get()).isTrue(); |
| var thrown = assertThrows(ExecutionException.class, future::get); |
| assertThat(thrown).hasCauseThat().isSameInstanceAs(expectedError); |
| } |
| |
| @Test |
| public void onFailure_recordsExceptionAndDecrementsCount() throws Exception { |
| AtomicBoolean doneWithErrorCalled = new AtomicBoolean(false); |
| var expectedError = new RuntimeException("subtask failed"); |
| |
| var future = |
| new AccumulatingQuiescingFuture<String, String>(safeDirectExecutor()) { |
| @Override |
| protected void accumulateFutureResult(String result) {} |
| |
| @Override |
| protected String getValue() { |
| return "unused"; |
| } |
| |
| @Override |
| protected void doneWithError( |
| @Nullable Throwable primaryCause, ImmutableList<Throwable> secondaryCauses) { |
| doneWithErrorCalled.set(true); |
| } |
| }; |
| |
| var subtask = SettableFuture.<String>create(); |
| future.addFuture(subtask, safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| subtask.setException(expectedError); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(doneWithErrorCalled.get()).isTrue(); |
| var thrown = assertThrows(ExecutionException.class, future::get); |
| assertThat(thrown).hasCauseThat().isSameInstanceAs(expectedError); |
| } |
| |
| @Test |
| public void addFuture_rejection_rollsBackCountAndCompletesExceptionally() throws Exception { |
| AtomicBoolean doneWithErrorCalled = new AtomicBoolean(false); |
| var rejectionException = new RejectedExecutionException("callback rejected"); |
| |
| SafeExecutor rejectingExecutor = |
| new SafeExecutor() { |
| @Override |
| public void execute(RejectionHandlingRunnable task) { |
| task.handleRejection(rejectionException); |
| } |
| |
| @Override |
| public <T> void addCallback( |
| ListenableFuture<T> future, FutureCallback<? super T> callback) { |
| throw rejectionException; |
| } |
| |
| @Override |
| public Executor getInternalUnsafeExecutor() { |
| return null; |
| } |
| }; |
| |
| var future = |
| new AccumulatingQuiescingFuture<String, String>(safeDirectExecutor()) { |
| @Override |
| protected void accumulateFutureResult(String result) {} |
| |
| @Override |
| protected String getValue() { |
| return "result"; |
| } |
| |
| @Override |
| protected void doneWithError( |
| @Nullable Throwable primaryCause, ImmutableList<Throwable> secondaryCauses) { |
| doneWithErrorCalled.set(true); |
| } |
| }; |
| |
| var subtask = SettableFuture.<String>create(); |
| future.addFuture(subtask, rejectingExecutor); |
| future.finishRegistration(); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(doneWithErrorCalled.get()).isTrue(); |
| var thrown = assertThrows(ExecutionException.class, future::get); |
| assertThat(thrown).hasCauseThat().isSameInstanceAs(rejectionException); |
| } |
| |
| @SuppressWarnings("AssertThrowsMinimizer") // safeDirectExecutor() does not throw exceptions |
| @Test |
| public void addFuture_nullFuture_throwsNullPointerException() { |
| var future = new AccumulatingStringFuture(); |
| assertThrows(NullPointerException.class, () -> future.addFuture(null, safeDirectExecutor())); |
| } |
| |
| @Test |
| public void addFuture_nullExecutor_throwsNullPointerException() { |
| var future = new AccumulatingStringFuture(); |
| var subtask = SettableFuture.<String>create(); |
| assertThrows(NullPointerException.class, () -> future.addFuture(subtask, null)); |
| } |
| |
| @Test |
| public void multipleSubtasks_concurrentCompletion() throws Exception { |
| var future = new AccumulatingStringFuture(); |
| int count = 50; |
| List<SettableFuture<String>> subtasks = new ArrayList<>(count); |
| |
| for (int i = 0; i < count; i++) { |
| var subtask = SettableFuture.<String>create(); |
| subtasks.add(subtask); |
| future.addFuture(subtask, safeDirectExecutor()); |
| } |
| future.finishRegistration(); |
| |
| var latch = new CountDownLatch(count); |
| for (int i = 0; i < count; i++) { |
| final int index = i; |
| commonPool() |
| .execute( |
| () -> { |
| subtasks.get(index).set("item-" + index); |
| latch.countDown(); |
| }); |
| } |
| |
| latch.await(); |
| assertThat(future.isDone()).isTrue(); |
| assertThat(future.get()).hasSize(count); |
| } |
| |
| @Test |
| public void multipleSubtasks_multipleFailures_preservesSecondaryExceptions() throws Exception { |
| AtomicReference<Throwable> capturedPrimary = new AtomicReference<>(); |
| AtomicReference<ImmutableList<Throwable>> capturedSecondaries = new AtomicReference<>(); |
| |
| var future = |
| new AccumulatingQuiescingFuture<String, String>(safeDirectExecutor()) { |
| @Override |
| protected void accumulateFutureResult(String result) {} |
| |
| @Override |
| protected String getValue() { |
| return "unused"; |
| } |
| |
| @Override |
| protected void doneWithError( |
| @Nullable Throwable primaryCause, ImmutableList<Throwable> secondaryCauses) { |
| capturedPrimary.set(primaryCause); |
| capturedSecondaries.set(secondaryCauses); |
| } |
| }; |
| |
| var error1 = new RuntimeException("error1"); |
| var error2 = new RuntimeException("error2"); |
| var subtask1 = SettableFuture.<String>create(); |
| var subtask2 = SettableFuture.<String>create(); |
| |
| future.addFuture(subtask1, safeDirectExecutor()); |
| future.addFuture(subtask2, safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| subtask1.setException(error1); |
| subtask2.setException(error2); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(capturedPrimary.get()).isSameInstanceAs(error1); |
| assertThat(capturedSecondaries.get()).containsExactly(error2); |
| |
| var thrown = assertThrows(ExecutionException.class, future::get); |
| assertThat(thrown).hasCauseThat().isSameInstanceAs(error1); |
| } |
| |
| @Test |
| public void immediateFutures_resolveImmediately() throws Exception { |
| var future = new AccumulatingStringFuture(); |
| future.addFuture(immediateFuture("a"), safeDirectExecutor()); |
| future.addFuture(immediateFuture("b"), safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(future.get()).containsExactly("a", "b").inOrder(); |
| } |
| |
| @Test |
| public void accumulateFuture_cancelledSubtask_cancelsAccumulatingFuture() throws Exception { |
| var future = new AccumulatingStringFuture(); |
| var subtask1 = SettableFuture.<String>create(); |
| var subtask2 = SettableFuture.<String>create(); |
| |
| future.addFuture(subtask1, safeDirectExecutor()); |
| future.addFuture(subtask2, safeDirectExecutor()); |
| future.finishRegistration(); |
| |
| assertThat(future.isDone()).isFalse(); |
| |
| subtask1.cancel(/* mayInterruptIfRunning= */ false); |
| |
| assertThat(future.isDone()).isTrue(); |
| assertThat(future.isCancelled()).isTrue(); |
| assertThrows(CancellationException.class, future::get); |
| } |
| |
| private static final class AccumulatingStringFuture |
| extends AccumulatingQuiescingFuture<ImmutableList<String>, String> { |
| private final ConcurrentLinkedQueue<String> collected = new ConcurrentLinkedQueue<>(); |
| |
| private AccumulatingStringFuture() { |
| super(safeDirectExecutor()); |
| } |
| |
| @Override |
| protected void accumulateFutureResult(String result) { |
| collected.add(result); |
| } |
| |
| @Override |
| protected ImmutableList<String> getValue() { |
| return ImmutableList.copyOf(collected); |
| } |
| } |
| } |