diff --git a/client/src/main/java/com/google/cloud/gcs/analyticscore/client/BatchExecutor.java b/client/src/main/java/com/google/cloud/gcs/analyticscore/client/BatchExecutor.java new file mode 100644 index 000000000..a948e77c9 --- /dev/null +++ b/client/src/main/java/com/google/cloud/gcs/analyticscore/client/BatchExecutor.java @@ -0,0 +1,396 @@ +/* + * Copyright 2026 Google LLC + * + * 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.cloud.gcs.analyticscore.client; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.common.util.concurrent.Uninterruptibles; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.Iterator; +import java.util.Queue; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Runs independent GCS requests, such as object deletes or moves, on a bounded thread pool. + * + *

Create one instance per logical operation and close it (or call {@link #shutdown()}) when it + * is done. An instance cannot be reused after shutdown. + * + *

Threading contract: a single owner thread calls {@link #queue}, {@link + * #awaitRequestsCompletion()} and {@link #shutdown()} (or {@link #close()}). Callbacks run on pool + * threads, or on the owner thread when the pool is saturated or {@code numThreads} is 0, and may + * call {@link #queue} before returning (for example to retry a request, though callbacks must not + * re-queue on {@link InterruptedException} or {@link CancellationException}). Calling {@code + * awaitRequestsCompletion()} or {@code shutdown()} concurrently from several threads is not + * supported. + * + *

Failures: pass a callback that records failures to handle per-request errors; the + * request's future then completes normally ({@code onFailure} for an interrupted request runs with + * the thread's interrupt status set). Exceptions from requests without a callback, {@link Error}s, + * and exceptions thrown by callbacks are reported by {@link #awaitRequestsCompletion()} and {@link + * #shutdown()}, which wait for every queued request before throwing. If the calling thread is + * interrupted, the remaining requests are cancelled instead and may still be running after the + * method throws (see {@link #awaitRequestsCompletion()}). + */ +final class BatchExecutor implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(BatchExecutor.class); + + // TODO: Benchmark and determine the optimal default batch executor configuration; currently + // mirrors hadoop-connectors defaults. + /** Keep-alive time for idle pool threads. */ + private static final long KEEP_ALIVE_SECONDS = 10L; + + /** Multiplier bounding the task queue size relative to thread count. */ + private static final int QUEUE_CAPACITY_PER_THREAD = 20; + + /** Maximum number of later failures attached as suppressed to the first failure. */ + static final int MAX_SUPPRESSED = 100; + + /** Grace period for pool termination; only after an interrupt may requests still be running. */ + private static final long SHUTDOWN_GRACE_PERIOD_SECONDS = 1L; + + private static final AtomicInteger POOL_COUNTER = new AtomicInteger(); + + private final ExecutorService requestsExecutor; + private final Queue> responseFutures = new ConcurrentLinkedQueue<>(); + + /** Number of {@link #queue} calls between {@link #dropUnreportableFutures()} scans. */ + private final int harvestInterval; + + private final AtomicInteger queueCalls = new AtomicInteger(); + private boolean closed; + + BatchExecutor(int numThreads) { + checkArgument( + numThreads >= 0 && numThreads <= GcsClientOptions.MAX_BATCH_THREADS, + "numThreads must be between 0 and %s, got %s", + GcsClientOptions.MAX_BATCH_THREADS, + numThreads); + this.requestsExecutor = + numThreads == 0 ? MoreExecutors.newDirectExecutorService() : newRequestExecutor(numThreads); + this.harvestInterval = Math.max(1, numThreads) * (QUEUE_CAPACITY_PER_THREAD + 2); + } + + private static ThreadPoolExecutor newRequestExecutor(int numThreads) { + ThreadPoolExecutor executor = + new ThreadPoolExecutor( + /* corePoolSize= */ numThreads, + /* maximumPoolSize= */ numThreads, + KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(numThreads * QUEUE_CAPACITY_PER_THREAD), + new ThreadFactoryBuilder() + .setNameFormat( + "gcs-batch-executor-pool-" + POOL_COUNTER.getAndIncrement() + "-thread-%d") + .setDaemon(true) + .build()); + executor.allowCoreThreadTimeOut(true); + // When saturated, run the request on the submitting thread (backpressure). Once shut down, + // reject it instead of silently dropping it as CallerRunsPolicy would. + executor.setRejectedExecutionHandler( + (request, pool) -> { + if (pool.isShutdown()) { + throw new RejectedExecutionException("BatchExecutor has been shut down"); + } + request.run(); + }); + return executor; + } + + /** + * Queues {@code task}. If {@code callback} is non-null, it receives the task's result or failure. + * + * @throws IllegalStateException if this executor has been shut down + */ + void queue(Callable task, @Nullable FutureCallback callback) { + checkNotNull(task, "task must not be null"); + checkState(!requestsExecutor.isShutdown(), "BatchExecutor has been shut down"); + Future future; + try { + future = + requestsExecutor.submit( + () -> { + execute(task, callback); + return null; + }); + } catch (RejectedExecutionException e) { + throw new IllegalStateException("BatchExecutor has been shut down", e); + } + responseFutures.add(future); + if (requestsExecutor.isShutdown()) { + future.cancel(false); + } + if (queueCalls.incrementAndGet() % harvestInterval == 0) { + dropUnreportableFutures(); + } + } + + /** + * Drops completed futures whose outcome {@link #awaitRequestsCompletion()} would never report, so + * memory and scan cost stay bounded: futures that completed normally, and failed or cancelled + * futures beyond the first {@code MAX_SUPPRESSED + 1} in queue order (always retaining the first + * {@link Error} so it can be prioritized over earlier {@link IOException}s). + */ + private void dropUnreportableFutures() { + int failed = 0; + boolean hasError = false; + for (Iterator> it = responseFutures.iterator(); it.hasNext(); ) { + Future future = it.next(); + if (!future.isDone()) { + continue; + } + Throwable failure = getFailureOrNull(future); + if (failure == null) { + it.remove(); + } else if (failure instanceof Error && !hasError) { + hasError = true; + failed++; + } else if (++failed > MAX_SUPPRESSED + 1) { + it.remove(); + } + } + } + + @Nullable + private static Throwable getFailureOrNull(Future doneFuture) { + try { + Uninterruptibles.getUninterruptibly(doneFuture); + return null; + } catch (ExecutionException e) { + return e.getCause() != null ? e.getCause() : e; + } catch (CancellationException e) { + return e; + } + } + + private static void execute(Callable task, @Nullable FutureCallback callback) + throws Exception { + T result; + try { + result = task.call(); + } catch (Throwable t) { + if (t instanceof InterruptedException) { + // Preserve the interrupt status of the thread that ran the task. + Thread.currentThread().interrupt(); + } + if (callback == null) { + throw t; + } + try { + callback.onFailure(t); + } catch (Throwable callbackFailure) { + if (t instanceof Error) { + if (callbackFailure != t) { + t.addSuppressed(callbackFailure); + } + throw (Error) t; + } + if (callbackFailure != t) { + callbackFailure.addSuppressed(t); + } + throw callbackFailure; + } + if (t instanceof Error) { + throw (Error) t; // Never swallow Errors such as OutOfMemoryError. + } + return; + } + // Outside the try block so that an exception from onSuccess never reaches onFailure. + if (callback != null) { + callback.onSuccess(result); + } + } + + @VisibleForTesting + int getPendingFuturesCount() { + return responseFutures.size(); + } + + /** + * Waits for every queued request, including requests that callbacks queue while it waits, and + * then throws the first failure with up to {@link #MAX_SUPPRESSED} later failures suppressed + * (prioritizing {@link Error} over {@link IOException}). The executor stays usable, so + * multi-phase operations can call this between phases. + * + *

Queue every request of the current phase before calling this method. Only requests queued by + * the owner thread before the call, or by callbacks of the requests it awaits, are awaited. + * + * @throws InterruptedIOException if the calling thread is interrupted while waiting for an + * unfinished request (remaining requests are cancelled and the interrupt status is restored), + * or if the first failed request was itself interrupted or threw an InterruptedIOException + * (all requests were awaited). A request interrupted while running on the calling thread + * leaves the calling thread interrupted, so a later call cancels unfinished requests as + * described above. + */ + void awaitRequestsCompletion() throws IOException { + Throwable failure = null; + int suppressedCount = 0; + Future future; + while ((future = responseFutures.poll()) != null) { + try { + getFromFuture(future); + } catch (InterruptedException e) { + future.cancel(true); + cancelRemainingFutures(); + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = + new InterruptedIOException("Interrupted while awaiting batch requests"); + interrupted.initCause(e); + if (failure instanceof Error) { + if (suppressedCount < MAX_SUPPRESSED) { + failure.addSuppressed(interrupted); + } + throw (Error) failure; + } + if (failure != null) { + interrupted.addSuppressed(failure); + } + throw interrupted; + } catch (IOException | Error e) { + if (failure == null) { + failure = e; + } else if (e instanceof Error && !(failure instanceof Error)) { + e.addSuppressed(failure); + if (suppressedCount < MAX_SUPPRESSED) { + suppressedCount++; + } + failure = e; + } else if (e != failure && suppressedCount < MAX_SUPPRESSED) { + failure.addSuppressed(e); + suppressedCount++; + } + } + } + if (failure instanceof Error) { + throw (Error) failure; + } + if (failure != null) { + throw (IOException) failure; + } + } + + /** + * Waits for {@code future}, rethrowing any {@link Error} directly and translating other failures + * into an {@link IOException}. + */ + private static void getFromFuture(Future future) throws IOException, InterruptedException { + try { + if (future.isDone()) { + // Done Guava futures (numThreads == 0) throw if interrupted; read the outcome regardless. + Uninterruptibles.getUninterruptibly(future); + } else { + future.get(); + } + } catch (CancellationException e) { + throw new IOException("Batch request was cancelled", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Error) { + throw (Error) cause; + } + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof InterruptedException) { + InterruptedIOException interrupted = + new InterruptedIOException("Batch request was interrupted"); + interrupted.initCause(cause); + throw interrupted; + } + throw new IOException("Batch request failed", cause); + } + } + + private void cancelRemainingFutures() { + Future future; + while ((future = responseFutures.poll()) != null) { + future.cancel(true); + } + } + + /** + * Stops the pool and cancels the requests it never started, so their futures always complete even + * if they are tracked after {@link #cancelRemainingFutures()} runs. + */ + private void forceShutdown() { + for (Runnable neverStarted : requestsExecutor.shutdownNow()) { + if (neverStarted instanceof Future) { + ((Future) neverStarted).cancel(false); + } + } + } + + /** Equivalent to {@link #shutdown()}, for use with try-with-resources. */ + @Override + public void close() throws IOException { + shutdown(); + } + + /** + * Waits for all queued requests like {@link #awaitRequestsCompletion()} and then shuts down the + * pool, even if waiting fails. Calling it again has no effect. + */ + void shutdown() throws IOException { + if (closed) { + return; + } + try { + awaitRequestsCompletion(); + } finally { + closed = true; + requestsExecutor.shutdown(); + // Clear the interrupt status so the grace-period wait is not cut short; restored below. + boolean interrupted = Thread.interrupted(); + try { + if (!requestsExecutor.awaitTermination(SHUTDOWN_GRACE_PERIOD_SECONDS, TimeUnit.SECONDS)) { + LOG.warn("Forcibly shutting down batch executor thread pool."); + forceShutdown(); + } + } catch (InterruptedException e) { + interrupted = true; + forceShutdown(); + } finally { + // Complete futures whose requests never ran so that later waits cannot block forever. + cancelRemainingFutures(); + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + } +} diff --git a/client/src/main/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptions.java b/client/src/main/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptions.java index d86d4b6d4..389033cb0 100644 --- a/client/src/main/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptions.java +++ b/client/src/main/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptions.java @@ -57,6 +57,12 @@ public abstract class GcsClientOptions { private static final String PCU_PART_FILE_NAME_PREFIX_KEY = "channel.write.pcu.part-file.name-prefix"; private static final String TEMPORARY_PATHS_KEY = "channel.write.temporary-paths"; + static final String BATCH_THREADS_KEY = "batch.threads"; + + // TODO: Benchmark and determine the optimal default batch executor configuration; currently + // mirrors hadoop-connectors defaults. + private static final int DEFAULT_BATCH_THREADS = 15; + static final int MAX_BATCH_THREADS = 1024; /** * Upload strategies matching the configurations offered by the google-cloud-storage Java client. @@ -141,6 +147,17 @@ boolean isGrpcEnabled() { public abstract ImmutableSet getTemporaryPaths(); + /** + * Returns the maximum number of threads used per batch operation (default {@code 15}, valid range + * {@code [0, 1024]}). + * + *

When {@code 0}, batch requests run synchronously on the calling thread. Configured via the + * {@code batch.threads} option key under the embedder prefix (for example {@code + * fs.gs.batch.threads}); values outside {@code [0, 1024]} are rejected by {@link + * Builder#build()}. + */ + public abstract int getBatchThreads(); + public abstract Builder toBuilder(); // TODO: Benchmark and determine the optimal default values for write options. @@ -156,7 +173,8 @@ public static Builder builder() { .setPcuBufferCapacity(32 * MB) .setPcuPartFileCleanupType(PartFileCleanupType.ALWAYS) .setPcuPartFileNamePrefix("") - .setTemporaryPaths(ImmutableSet.of()); + .setTemporaryPaths(ImmutableSet.of()) + .setBatchThreads(DEFAULT_BATCH_THREADS); } public static GcsClientOptions createFromOptions( @@ -207,6 +225,9 @@ public static GcsClientOptions createFromOptions( .filter(s -> !s.isEmpty()) .collect(Collectors.toList())) .ifPresent(optionsBuilder::setTemporaryPaths); + ConfigurationUtil.getTrimmedValue(analyticsCoreOptions, prefix + BATCH_THREADS_KEY) + .map(val -> ConfigurationUtil.safeParseInteger(prefix + BATCH_THREADS_KEY, val)) + .ifPresent(optionsBuilder::setBatchThreads); optionsBuilder.setGcsReadOptions( GcsReadOptions.createFromOptions(analyticsCoreOptions, prefix)); @@ -258,6 +279,14 @@ public Builder setTemporaryPaths(Collection paths) { return setTemporaryPaths(ImmutableSet.copyOf(paths)); } + /** + * Sets the maximum number of threads used per batch operation (default {@code 15}). + * + *

Must be in {@code [0, 1024]} (validated by {@link #build()}); {@code 0} runs batch + * requests synchronously on the calling thread. + */ + public abstract Builder setBatchThreads(int batchThreads); + abstract GcsClientOptions autoBuild(); public GcsClientOptions build() { @@ -268,6 +297,17 @@ public GcsClientOptions build() { "Appendable writes require %s=BIDI, but was %s", CLIENT_PROTOCOL_KEY, options.getProtocol()); + checkArgument( + options.getBatchThreads() >= 0, + "%s must be non-negative, but was %s", + BATCH_THREADS_KEY, + options.getBatchThreads()); + checkArgument( + options.getBatchThreads() <= MAX_BATCH_THREADS, + "%s must be at most %s, but was %s", + BATCH_THREADS_KEY, + MAX_BATCH_THREADS, + options.getBatchThreads()); return options; } } diff --git a/client/src/test/java/com/google/cloud/gcs/analyticscore/client/BatchExecutorTest.java b/client/src/test/java/com/google/cloud/gcs/analyticscore/client/BatchExecutorTest.java new file mode 100644 index 000000000..19d40f348 --- /dev/null +++ b/client/src/test/java/com/google/cloud/gcs/analyticscore/client/BatchExecutorTest.java @@ -0,0 +1,753 @@ +/* + * Copyright 2026 Google LLC + * + * 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.cloud.gcs.analyticscore.client; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Uninterruptibles; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import javax.annotation.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class BatchExecutorTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + /** Mirrors BatchExecutor: the pool queue holds 20 requests per thread. */ + private static final int QUEUE_CAPACITY_PER_THREAD = 20; + + /** Mirrors BatchExecutor: succeeded futures are dropped every max(1, numThreads) * 22 calls. */ + private static final int HARVEST_INTERVAL_PER_THREAD = QUEUE_CAPACITY_PER_THREAD + 2; + + /** Requests block on this gate; tearDown opens it so that a failed test never strands threads. */ + private final CountDownLatch gate = new CountDownLatch(1); + + /** Counted down by every request that blocks on the gate. */ + private final CountDownLatch started = new CountDownLatch(1); + + private final List executors = new ArrayList<>(); + + @AfterEach + void tearDown() { + Thread.interrupted(); // Never leak an interrupt into later tests. + gate.countDown(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + for (BatchExecutor executor : executors) { + try { + executor.shutdown(); + } catch (IOException ignored) { + // Expected failures were asserted by the test; unexpected ones already failed it. + } + } + }); + } + + @Test + void queue_runsTaskOnPoolThreadAndCallsOnSuccess() { + BatchExecutor executor = newExecutor(2); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(Thread::currentThread, callback); + executor.awaitRequestsCompletion(); + + assertThat(callback.failures).isEmpty(); + assertThat(callback.successes).containsExactly(callback.thread); + assertThat(callback.thread).isNotSameInstanceAs(Thread.currentThread()); + assertThat(callback.thread.getName()).startsWith("gcs-batch-executor-pool-"); + assertThat(callback.thread.isDaemon()).isTrue(); + }); + } + + @Test + void queue_withZeroThreads_runsTaskSynchronouslyOnCallerThread() throws IOException { + BatchExecutor executor = newExecutor(0); // Never blocks: requests run inside queue(). + RecordingCallback callback = new RecordingCallback<>(); + IOException failure = new IOException("request failed"); + + executor.queue(Thread::currentThread, callback); + + assertThat(callback.successes).containsExactly(Thread.currentThread()); + assertThat(callback.thread).isSameInstanceAs(Thread.currentThread()); + // Every request has completed, so an owner interrupt neither cancels nor loses any of them. + executor.queue(failWith(failure), null); + Thread.currentThread().interrupt(); + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).isSameInstanceAs(failure); + executor.queue(() -> "ok", null); + executor.shutdown(); // Does not throw although the owner is still interrupted. + assertThat(Thread.interrupted()).isTrue(); + } + + @Test + void queue_whenPoolIsSaturated_runsTaskOnCallerThread() { + BatchExecutor executor = newExecutor(1); + AtomicInteger queuedRuns = new AtomicInteger(); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(this::blockOnGate, null); + started.await(); + for (int i = 0; i < QUEUE_CAPACITY_PER_THREAD; i++) { + executor.queue(queuedRuns::incrementAndGet, null); + } + executor.queue(Thread::currentThread, callback); + + assertThat(callback.successes).containsExactly(Thread.currentThread()); + assertThat(callback.thread).isSameInstanceAs(Thread.currentThread()); + assertThat(queuedRuns.get()).isEqualTo(0); + gate.countDown(); + executor.awaitRequestsCompletion(); + assertThat(queuedRuns.get()).isEqualTo(QUEUE_CAPACITY_PER_THREAD); + }); + } + + @Test + void constructor_withOutOfRangeThreads_throwsIllegalArgumentException() { + int tooMany = GcsClientOptions.MAX_BATCH_THREADS + 1; + + IllegalArgumentException negative = + assertThrows(IllegalArgumentException.class, () -> new BatchExecutor(-1)); + IllegalArgumentException excessive = + assertThrows(IllegalArgumentException.class, () -> new BatchExecutor(tooMany)); + + assertThat(negative).hasMessageThat().contains("got -1"); + assertThat(excessive).hasMessageThat().contains("got " + tooMany); + newExecutor(0); // Both bounds are accepted. + newExecutor(GcsClientOptions.MAX_BATCH_THREADS); + } + + @Test + void queue_whenOnSuccessThrows_neverCallsOnFailureAndAwaitThrows() { + BatchExecutor executor = newExecutor(2); + IllegalStateException callbackFailure = new IllegalStateException("onSuccess failed"); + RecordingCallback callback = new RecordingCallback().throwing(callbackFailure); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(() -> "result", callback); + + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + + assertThat(thrown).hasCauseThat().isSameInstanceAs(callbackFailure); + assertThat(callback.successes).containsExactly("result"); + assertThat(callback.failures).isEmpty(); + }); + } + + @Test + void queue_whenTaskFailsWithCallback_callsOnFailureAndAwaitDoesNotThrow() { + BatchExecutor executor = newExecutor(2); + IOException failure = new IOException("request failed"); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(failure), callback); + executor.awaitRequestsCompletion(); + assertThat(callback.failures).containsExactly(failure); + assertThat(callback.successes).isEmpty(); + }); + } + + @Test + void awaitRequestsCompletion_withoutCallback_rethrowsIOExceptionAndWrapsOthers() { + BatchExecutor executor = newExecutor(2); + IOException ioFailure = new IOException("request failed"); + IllegalArgumentException runtimeFailure = new IllegalArgumentException("bad request"); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(ioFailure), null); + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).isSameInstanceAs(ioFailure); + + executor.queue(failWith(runtimeFailure), null); + thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).hasCauseThat().isSameInstanceAs(runtimeFailure); + }); + } + + @Test + void queue_whenTaskThrowsErrorWithCallback_callsOnFailureAndAwaitRethrowsError() { + BatchExecutor executor = newExecutor(2); + IOException earlierFailure = new IOException("earlier request failed"); + OutOfMemoryError error = new OutOfMemoryError("simulated"); + RecordingCallback callback = new RecordingCallback<>(); + OutOfMemoryError errorWithFailingCallback = new OutOfMemoryError("simulated with callback bug"); + IllegalStateException callbackFailure = new IllegalStateException("onFailure failed"); + RecordingCallback failingCallback = + new RecordingCallback().throwing(callbackFailure); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(earlierFailure), null); + executor.queue( + () -> { + throw error; + }, + callback); + + OutOfMemoryError thrown = + assertThrows(OutOfMemoryError.class, executor::awaitRequestsCompletion); + + assertThat(callback.failures).containsExactly(error); + assertThat(thrown).isSameInstanceAs(error); + assertThat(error.getSuppressed()).asList().containsExactly(earlierFailure); + + executor.queue( + () -> { + throw errorWithFailingCallback; + }, + failingCallback); + + thrown = assertThrows(OutOfMemoryError.class, executor::awaitRequestsCompletion); + + assertThat(failingCallback.failures).containsExactly(errorWithFailingCallback); + assertThat(thrown).isSameInstanceAs(errorWithFailingCallback); + assertThat(errorWithFailingCallback.getSuppressed()) + .asList() + .containsExactly(callbackFailure); + }); + } + + @Test + void queue_whenOnFailureThrows_awaitThrowsWithTaskFailureSuppressed() { + BatchExecutor executor = newExecutor(2); + IOException taskFailure = new IOException("request failed"); + IllegalStateException callbackFailure = new IllegalStateException("onFailure failed"); + RecordingCallback callback = new RecordingCallback().throwing(callbackFailure); + IllegalArgumentException rethrownException = + new IllegalArgumentException("rethrown by callback"); + AssertionError rethrownError = new AssertionError("rethrown error by callback"); + FutureCallback rethrowingCallback = + new RecordingCallback() { + @Override + public void onFailure(Throwable t) { + super.onFailure(t); + if (t instanceof Error) { + throw (Error) t; + } + throw (RuntimeException) t; + } + }; + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(taskFailure), callback); + + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + + assertThat(callback.failures).containsExactly(taskFailure); + assertThat(thrown).hasCauseThat().isSameInstanceAs(callbackFailure); + assertThat(callbackFailure.getSuppressed()).asList().containsExactly(taskFailure); + + executor.queue(failWith(rethrownException), rethrowingCallback); + thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).hasCauseThat().isSameInstanceAs(rethrownException); + assertThat(rethrownException.getSuppressed()).isEmpty(); + + executor.queue( + () -> { + throw rethrownError; + }, + rethrowingCallback); + AssertionError thrownError = + assertThrows(AssertionError.class, executor::awaitRequestsCompletion); + assertThat(thrownError).isSameInstanceAs(rethrownError); + assertThat(rethrownError.getSuppressed()).isEmpty(); + }); + } + + @Test + void awaitRequestsCompletion_drainsAllRequestsBeforeThrowingFirstFailure() { + BatchExecutor executor = newExecutor(4); + IOException first = new IOException("first"); + IOException second = new IOException("second"); + IOException third = new IOException("third"); + RecordingCallback slow = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(first), null); + for (int i = 0; i < 3; i++) { + executor.queue(this::blockOnGate, slow); + } + executor.queue(failWith(second), null); + executor.queue(failWith(third), null); + // Release the slow requests only once the barrier has consumed the first failure. + runWhen(() -> executor.getPendingFuturesCount() <= 4, gate::countDown); + + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + + assertThat(slow.successes).hasSize(3); + assertThat(slow.successes).containsNoDuplicates(); // One core pool thread per request. + assertThat(thrown).isSameInstanceAs(first); + assertThat(thrown.getSuppressed()).asList().containsExactly(second, third).inOrder(); + assertThat(executor.getPendingFuturesCount()).isEqualTo(0); + }); + } + + @Test + void awaitRequestsCompletion_capsSuppressedFailures() { + BatchExecutor executor = newExecutor(0); // Never blocks, and harvest counts are exact. + int reportable = BatchExecutor.MAX_SUPPRESSED + 1; + List failures = new ArrayList<>(); + for (int i = 0; i < reportable + 2 * HARVEST_INTERVAL_PER_THREAD; i++) { + IOException failure = new IOException("failure " + i); + failures.add(failure); + executor.queue(failWith(failure), null); + } + // Harvests also drop the failures that awaitRequestsCompletion() could never report. + assertThat(executor.getPendingFuturesCount()) + .isLessThan(reportable + HARVEST_INTERVAL_PER_THREAD); + + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + + List later = failures.subList(1, reportable); + assertThat(thrown).isSameInstanceAs(failures.get(0)); + assertThat(thrown.getSuppressed()).asList().containsExactlyElementsIn(later).inOrder(); + assertThat(executor.getPendingFuturesCount()).isEqualTo(0); + } + + @Test + void awaitRequestsCompletion_whenErrorFollowsMaxSuppressedIoExceptions_retainsAndThrowsError() { + BatchExecutor executor = newExecutor(0); // Never blocks, and harvest counts are exact. + int reportable = BatchExecutor.MAX_SUPPRESSED + 1; + List ioFailures = new ArrayList<>(); + for (int i = 0; i < reportable + HARVEST_INTERVAL_PER_THREAD; i++) { + IOException failure = new IOException("failure " + i); + ioFailures.add(failure); + executor.queue(failWith(failure), null); + } + AssertionError error = new AssertionError("late fatal error"); + executor.queue( + () -> { + throw error; + }, + null); + for (int i = 0; i < HARVEST_INTERVAL_PER_THREAD; i++) { + executor.queue(() -> null, null); + } + + AssertionError thrown = assertThrows(AssertionError.class, executor::awaitRequestsCompletion); + + assertThat(thrown).isSameInstanceAs(error); + assertThat(thrown.getSuppressed()).asList().containsExactly(ioFailures.get(0)); + assertThat(ioFailures.get(0).getSuppressed()) + .asList() + .containsExactlyElementsIn(ioFailures.subList(1, reportable)) + .inOrder(); + } + + @Test + void awaitRequestsCompletion_whenRequestsShareOneException_throwsItWithoutSelfSuppression() { + BatchExecutor executor = newExecutor(0); // Never blocks: requests run inside queue(). + IOException shared = new IOException("shared failure"); + executor.queue(failWith(shared), null); + executor.queue(failWith(shared), null); + + // Suppressing the failure into itself would throw IllegalArgumentException instead. + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + + assertThat(thrown).isSameInstanceAs(shared); + assertThat(thrown.getSuppressed()).isEmpty(); + } + + @Test + void awaitRequestsCompletion_isReusablePhaseBarrier() { + BatchExecutor executor = newExecutor(4); + int phaseSize = 8; + IOException phase1Failure = new IOException("phase 1 failed"); + RecordingCallback phase1 = new RecordingCallback<>(); + RecordingCallback phase2 = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(phase1Failure), null); + for (int i = 0; i < phaseSize; i++) { + executor.queue(this::blockOnGate, phase1); + } + // Release phase 1 only once the barrier waits for one of its blocked requests. + runWhen(() -> executor.getPendingFuturesCount() < phaseSize, gate::countDown); + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).isSameInstanceAs(phase1Failure); + + for (int i = 0; i < phaseSize; i++) { + executor.queue(phase1.successes::size, phase2); // Phase 1 requests finished so far. + } + executor.awaitRequestsCompletion(); // Does not report the phase 1 failure again. + assertThat(phase2.successes) + .containsExactlyElementsIn(Collections.nCopies(phaseSize, phaseSize)); + }); + } + + @Test + void awaitRequestsCompletion_waitsForRetryQueuedByCallback() { + BatchExecutor executor = newExecutor(1); + RecordingCallback retry = new RecordingCallback<>(); + FutureCallback retryOnFailure = + new RecordingCallback() { + @Override + public void onFailure(Throwable t) { + executor.queue(Thread::currentThread, retry); + } + }; + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(this::blockOnGate, null); // Keeps the only pool thread busy. + executor.queue(failWith(new IOException("transient")), retryOnFailure); + // The failing request runs, and queues its retry, only once the barrier is waiting. + runWhen(() -> executor.getPendingFuturesCount() <= 1, gate::countDown); + + executor.awaitRequestsCompletion(); + + assertThat(retry.successes).hasSize(1); + assertThat(executor.getPendingFuturesCount()).isEqualTo(0); // The retry was awaited. + }); + } + + @Test + void queue_withManyFastTasks_keepsPendingFuturesBounded() { + int numThreads = 4; + BatchExecutor executor = newExecutor(numThreads); + AtomicInteger completed = new AtomicInteger(); + // Two harvest intervals of new futures plus every running or queued request. + int bound = numThreads * HARVEST_INTERVAL_PER_THREAD * 2 + numThreads * 21 + 1; + assertTimeoutPreemptively( + TIMEOUT, + () -> { + int maxPending = 0; + for (int i = 0; i < 10_000; i++) { + executor.queue(completed::incrementAndGet, null); + maxPending = Math.max(maxPending, executor.getPendingFuturesCount()); + } + executor.awaitRequestsCompletion(); + + assertThat(maxPending).isAtMost(bound); + assertThat(completed.get()).isEqualTo(10_000); + assertThat(executor.getPendingFuturesCount()).isEqualTo(0); + }); + } + + @Test + void queue_whenHarvestingSucceededFutures_keepsFailedOnesForAwait() { + BatchExecutor executor = newExecutor(0); // Never blocks, and counts are exact. + IOException first = new IOException("first"); + IOException second = new IOException("second"); + + executor.queue(failWith(first), null); + for (int call = 2; call <= 3 * HARVEST_INTERVAL_PER_THREAD; call++) { // Harvests 3 times. + executor.queue(call == HARVEST_INTERVAL_PER_THREAD + 1 ? failWith(second) : () -> "ok", null); + } + + assertThat(executor.getPendingFuturesCount()).isEqualTo(2); + IOException thrown = assertThrows(IOException.class, executor::awaitRequestsCompletion); + assertThat(thrown).isSameInstanceAs(first); + assertThat(thrown.getSuppressed()).asList().containsExactly(second); + } + + @Test + void awaitRequestsCompletion_whenOwnerInterrupted_throwsAndCancelsRemainingRequests() { + BatchExecutor executor = newExecutor(1); + RecordingCallback blocked = + new RecordingCallback() { + @Override + public void onFailure(Throwable t) { + super.onFailure(t); + Uninterruptibles.awaitUninterruptibly(gate); // Holds the only pool thread. + } + }; + RecordingCallback queued = new RecordingCallback<>(); + IOException earlier = new IOException("earlier"); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(earlier), null); // Must neither be lost nor mask the interrupt. + executor.queue(this::blockOnGate, blocked); + executor.queue(Thread::currentThread, queued); // Waits in the pool queue. + started.await(); + Thread owner = Thread.currentThread(); + // Interrupt the owner once the barrier has taken the blocked request and waits for it. + runWhen(() -> executor.getPendingFuturesCount() <= 1, owner::interrupt); + + InterruptedIOException thrown = + assertThrows(InterruptedIOException.class, executor::awaitRequestsCompletion); + + assertThat(Thread.interrupted()).isTrue(); // Restored; cleared for the waits below. + assertThat(thrown).hasCauseThat().isInstanceOf(InterruptedException.class); + assertThat(thrown.getSuppressed()).asList().containsExactly(earlier); + blocked.called.await(); // The gate is closed, so only cancel(true) can end the request. + assertThat(blocked.failures.get(0)).isInstanceOf(InterruptedException.class); + gate.countDown(); // The pool thread now runs whatever is left in its queue. + assertThat(executor.getPendingFuturesCount()).isEqualTo(0); + executor.shutdown(); + assertThat(queued.called.getCount()).isEqualTo(1); // Cancelled before it could run. + }); + } + + @Test + void awaitRequestsCompletion_whenPoolTaskThrowsInterruptedException_doesNotInterruptOwner() { + BatchExecutor executor = newExecutor(1); + InterruptedException interrupt = new InterruptedException("request interrupted"); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(failWith(interrupt), null); + executor.queue(failWith(new InterruptedException()), callback); + + InterruptedIOException thrown = + assertThrows(InterruptedIOException.class, executor::awaitRequestsCompletion); + + assertThat(thrown).hasCauseThat().isSameInstanceAs(interrupt); + assertThat(Thread.currentThread().isInterrupted()).isFalse(); + assertThat(callback.interrupted).isTrue(); // Restored on the pool thread. + }); + } + + @Test + void shutdown_waitsForInFlightRequestsThenTerminatesPool() { + BatchExecutor executor = newExecutor(2); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(this::blockOnGate, callback); + started.await(); + // Release the request only once shutdown() has taken its future and waits for it. + runWhen(() -> executor.getPendingFuturesCount() == 0, gate::countDown); + + executor.shutdown(); + + assertThat(callback.successes).hasSize(1); + assertTerminates(callback.thread); + assertThrows(IllegalStateException.class, () -> executor.queue(() -> null, null)); + }); + } + + @Test + void shutdown_isIdempotentAndRejectsLaterRequests() { + AtomicInteger runs = new AtomicInteger(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + for (int numThreads : new int[] {0, 2}) { + BatchExecutor executor = newExecutor(numThreads); + executor.queue(runs::incrementAndGet, null); + executor.shutdown(); + executor.shutdown(); + IllegalStateException e = + assertThrows( + IllegalStateException.class, () -> executor.queue(runs::incrementAndGet, null)); + assertThat(e).hasMessageThat().contains("shut down"); + } + assertThat(runs.get()).isEqualTo(2); // Rejected requests never ran. + }); + } + + @Test + void shutdown_whenAwaitThrows_stillShutsDownPool() { + BatchExecutor executor = newExecutor(1); + IOException failure = new IOException("request failed"); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + executor.queue(Thread::currentThread, callback); // Records the only pool thread. + executor.queue(failWith(failure), null); + + IOException thrown = assertThrows(IOException.class, executor::shutdown); + + assertThat(thrown).isSameInstanceAs(failure); + assertThrows(IllegalStateException.class, () -> executor.queue(() -> null, null)); + assertTerminates(callback.thread); + executor.shutdown(); // The failure was reported once; calling again has no effect. + }); + } + + @Test + void close_inTryWithResources_shutsDownPoolAndSuppressesTaskFailureOnPrimaryException() { + IOException primaryFailure = new IOException("listing failed with 403"); + IOException taskFailure = new IOException("queued delete failed"); + RecordingCallback callback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + BatchExecutor[] closedExecutor = new BatchExecutor[1]; + IOException thrown = + assertThrows( + IOException.class, + () -> { + try (BatchExecutor executor = newExecutor(1)) { + closedExecutor[0] = executor; + executor.queue(Thread::currentThread, callback); + executor.queue(failWith(taskFailure), null); + throw primaryFailure; + } + }); + + assertThat(thrown).isSameInstanceAs(primaryFailure); + assertThat(thrown.getSuppressed()).asList().containsExactly(taskFailure); + assertThrows( + IllegalStateException.class, () -> closedExecutor[0].queue(() -> null, null)); + assertTerminates(callback.thread); + }); + } + + @Test + void shutdown_withInterruptedCaller_terminatesPoolAndPreservesInterrupt() { + BatchExecutor idle = newExecutor(1); + BatchExecutor busy = newExecutor(1); + RecordingCallback idleCallback = new RecordingCallback<>(); + RecordingCallback busyCallback = new RecordingCallback<>(); + RecordingCallback queuedCallback = new RecordingCallback<>(); + assertTimeoutPreemptively( + TIMEOUT, + () -> { + idle.queue(Thread::currentThread, idleCallback); + idle.awaitRequestsCompletion(); // The pool thread stays alive for its keep-alive time. + Thread.currentThread().interrupt(); + idle.shutdown(); + assertThat(Thread.interrupted()).isTrue(); + assertTerminates(idleCallback.thread); + + busy.queue( + () -> { + try { + return blockOnGate(); + } catch (InterruptedException cancelled) { // Survives cancel(true)'s interrupt. + return blockOnGate(); + } + }, + busyCallback); + started.await(); + // Waits in the pool queue behind the blocked request until shutdownNow() drains it. + busy.queue(Thread::currentThread, queuedCallback); + Thread.currentThread().interrupt(); + long start = System.nanoTime(); // shutdown() must wait out its 1 s grace period first. + assertThrows(InterruptedIOException.class, busy::shutdown); + assertThat(System.nanoTime() - start).isAtLeast(Duration.ofSeconds(1).toNanos()); + assertThat(Thread.interrupted()).isTrue(); + busyCallback.called.await(); // Only shutdownNow() can interrupt the request again. + assertThat(busyCallback.failures.get(0)).isInstanceOf(InterruptedException.class); + assertTerminates(busyCallback.thread); + assertThat(queuedCallback.called.getCount()).isEqualTo(1); // It never ran. + assertThrows(IllegalStateException.class, () -> busy.queue(() -> null, null)); + }); + } + + private BatchExecutor newExecutor(int numThreads) { + BatchExecutor executor = new BatchExecutor(numThreads); + executors.add(executor); + return executor; + } + + /** A request that counts down started, waits for the gate and returns the thread it ran on. */ + private Thread blockOnGate() throws InterruptedException { + started.countDown(); + gate.await(); + return Thread.currentThread(); + } + + private static Callable failWith(Exception failure) { + return () -> { + throw failure; + }; + } + + /** + * Runs {@code action} on a helper thread once {@code condition} holds (or tearDown opens the + * gate). awaitRequestsCompletion() removes each future before waiting for it, so conditions on + * getPendingFuturesCount() show how far the owner thread has progressed. + */ + private void runWhen(BooleanSupplier condition, Runnable action) { + Thread helper = + new Thread( + () -> { + while (!condition.getAsBoolean() && gate.getCount() > 0) { + Thread.onSpinWait(); + } + if (condition.getAsBoolean()) { + action.run(); + } + }); + helper.setDaemon(true); + helper.start(); + } + + /** Asserts that {@code thread} exits well within the 10 s keep-alive of idle pool threads. */ + private static void assertTerminates(Thread thread) throws InterruptedException { + thread.join(TIMEOUT.toMillis() / 2); + assertThat(thread.isAlive()).isFalse(); + } + + /** Records each callback call and the state of its thread, then throws {@code toThrow} if set. */ + private static class RecordingCallback implements FutureCallback { + final List successes = new CopyOnWriteArrayList<>(); + final List failures = new CopyOnWriteArrayList<>(); + final CountDownLatch called = new CountDownLatch(1); + volatile Thread thread; + volatile boolean interrupted; + @Nullable private RuntimeException toThrow; + + RecordingCallback throwing(RuntimeException e) { + toThrow = e; + return this; + } + + @Override + public void onSuccess(T result) { + successes.add(result); + record(); + } + + @Override + public void onFailure(Throwable t) { + failures.add(t); + record(); + } + + private void record() { + thread = Thread.currentThread(); + interrupted = thread.isInterrupted(); + called.countDown(); + if (toThrow != null) { + throw toThrow; + } + } + } +} diff --git a/client/src/test/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptionsTest.java b/client/src/test/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptionsTest.java index 1cdf59a0c..be80762b9 100644 --- a/client/src/test/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptionsTest.java +++ b/client/src/test/java/com/google/cloud/gcs/analyticscore/client/GcsClientOptionsTest.java @@ -52,6 +52,7 @@ void builder_withDefaultValues_returnsExpectedDefaults() { .isEqualTo(GcsClientOptions.PartFileCleanupType.ALWAYS); assertThat(options.getPcuPartFileNamePrefix()).isEmpty(); assertThat(options.getTemporaryPaths()).isEmpty(); + assertThat(options.getBatchThreads()).isEqualTo(15); } @Test @@ -70,6 +71,7 @@ void builder_withCustomValues_setsAllProperties() { .setPcuPartFileCleanupType(GcsClientOptions.PartFileCleanupType.ON_SUCCESS) .setPcuPartFileNamePrefix("temp-prefix-") .setTemporaryPaths(ImmutableList.of("/tmp/path1", "/tmp/path2")) + .setBatchThreads(8) .build(); assertThat(options.getProjectId()).hasValue("test-project"); @@ -86,6 +88,7 @@ void builder_withCustomValues_setsAllProperties() { .isEqualTo(GcsClientOptions.PartFileCleanupType.ON_SUCCESS); assertThat(options.getPcuPartFileNamePrefix()).isEqualTo("temp-prefix-"); assertThat(options.getTemporaryPaths()).containsExactly("/tmp/path1", "/tmp/path2").inOrder(); + assertThat(options.getBatchThreads()).isEqualTo(8); } @Test @@ -104,6 +107,7 @@ void createFromOptions_withValidProperties_parsesCorrectly() { .put("gcs.channel.write.pcu.part-file.cleanup-type", "on_success") .put("gcs.channel.write.pcu.part-file.name-prefix", "temp-prefix-") .put("gcs.channel.write.temporary-paths", "/tmp/path1, /tmp/path2") + .put("gcs.batch.threads", "8") .build(); GcsClientOptions options = GcsClientOptions.createFromOptions(rawOptions, "gcs."); @@ -122,6 +126,7 @@ void createFromOptions_withValidProperties_parsesCorrectly() { .isEqualTo(GcsClientOptions.PartFileCleanupType.ON_SUCCESS); assertThat(options.getPcuPartFileNamePrefix()).isEqualTo("temp-prefix-"); assertThat(options.getTemporaryPaths()).containsExactly("/tmp/path1", "/tmp/path2").inOrder(); + assertThat(options.getBatchThreads()).isEqualTo(8); } @Test @@ -163,6 +168,109 @@ void createFromOptions_withOverflowingPcuBufferCapacity_throwsIllegalArgumentExc assertOverflowThrows("gcs.channel.write.pcu.buffer.capacity-bytes"); } + @Test + void createFromOptions_withOverflowingBatchThreads_throwsIllegalArgumentException() { + assertOverflowThrows("gcs.batch.threads"); + } + + @Test + void createFromOptions_withZeroBatchThreads_allowsDirectExecutorMode() { + GcsClientOptions options = + GcsClientOptions.createFromOptions(ImmutableMap.of("gcs.batch.threads", "0"), "gcs."); + + assertThat(options.getBatchThreads()).isEqualTo(0); + } + + @Test + void createFromOptions_withWhitespaceBatchThreads_parsesCorrectly() { + GcsClientOptions options = + GcsClientOptions.createFromOptions(ImmutableMap.of("gcs.batch.threads", " 12 "), "gcs."); + + assertThat(options.getBatchThreads()).isEqualTo(12); + } + + @Test + void createFromOptions_withEmptyOrBlankBatchThreads_retainsDefault() { + GcsClientOptions emptyOptions = + GcsClientOptions.createFromOptions(ImmutableMap.of("gcs.batch.threads", ""), "gcs."); + GcsClientOptions blankOptions = + GcsClientOptions.createFromOptions(ImmutableMap.of("gcs.batch.threads", " "), "gcs."); + + assertThat(emptyOptions.getBatchThreads()).isEqualTo(15); + assertThat(blankOptions.getBatchThreads()).isEqualTo(15); + } + + @Test + void createFromOptions_withExcessiveBatchThreads_throwsIllegalArgumentException() { + Map rawOptions = + ImmutableMap.of( + "gcs.batch.threads", String.valueOf(GcsClientOptions.MAX_BATCH_THREADS + 1)); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> GcsClientOptions.createFromOptions(rawOptions, "gcs.")); + + assertThat(exception).hasMessageThat().contains("batch.threads must be at most"); + } + + @Test + void builder_withZeroBatchThreads_buildsSuccessfully() { + GcsClientOptions options = GcsClientOptions.builder().setBatchThreads(0).build(); + + assertThat(options.getBatchThreads()).isEqualTo(0); + } + + @Test + void builder_withMaxBatchThreads_buildsSuccessfully() { + GcsClientOptions options = + GcsClientOptions.builder().setBatchThreads(GcsClientOptions.MAX_BATCH_THREADS).build(); + + assertThat(options.getBatchThreads()).isEqualTo(GcsClientOptions.MAX_BATCH_THREADS); + } + + @Test + void builder_withNegativeBatchThreads_throwsIllegalArgumentException() { + GcsClientOptions.Builder builder = GcsClientOptions.builder().setBatchThreads(-1); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(exception).hasMessageThat().contains("batch.threads must be non-negative"); + } + + @Test + void createFromOptions_withNegativeBatchThreads_throwsIllegalArgumentException() { + Map rawOptions = ImmutableMap.of("gcs.batch.threads", "-1"); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> GcsClientOptions.createFromOptions(rawOptions, "gcs.")); + + assertThat(exception).hasMessageThat().contains("batch.threads must be non-negative"); + } + + @Test + void builder_withExcessiveBatchThreads_throwsIllegalArgumentException() { + GcsClientOptions.Builder builder = + GcsClientOptions.builder().setBatchThreads(GcsClientOptions.MAX_BATCH_THREADS + 1); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(exception).hasMessageThat().contains("batch.threads must be at most"); + } + + @Test + void createFromOptions_withNonNumericBatchThreads_throwsIllegalArgumentException() { + Map rawOptions = ImmutableMap.of("gcs.batch.threads", "not-a-number"); + + assertThrows( + IllegalArgumentException.class, + () -> GcsClientOptions.createFromOptions(rawOptions, "gcs.")); + } + private void assertOverflowThrows(String key) { Map rawOptions = ImmutableMap.of(key, String.valueOf((long) Integer.MAX_VALUE + 1L));