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

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


The following commit(s) were added to refs/heads/master by this push:
     new 8cae4991fca [Java] Close GcsUtilV2's small-file performance gaps with 
GcsUtilV1 (#40677)
8cae4991fca is described below

commit 8cae4991fca407818454ae5bc5ac1e5f70c03411
Author: Shunping Huang <[email protected]>
AuthorDate: Wed Oct 21 11:43:35 2026 -0400

    [Java] Close GcsUtilV2's small-file performance gaps with GcsUtilV1 (#40677)
    
    * Use the shared HTTP transport in GcsUtilV2
    
    * Start GcsUtilV2 upload sessions in the background
    
    * Raise the default GcsUtilV2 rewrite concurrency to 128
    
    * Handle closed and failed upload sessions in GcsWritableByteChannel
---
 .../sdk/extensions/gcp/options/GcsOptions.java     |   4 +-
 .../beam/sdk/extensions/gcp/util/GcsUtilV2.java    | 125 +++++++++++--
 .../sdk/extensions/gcp/util/GcsUtilV2Test.java     | 198 +++++++++++++++++++--
 3 files changed, 300 insertions(+), 27 deletions(-)

diff --git 
a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java
 
b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java
index 16acb90d11a..b5246a87d2b 100644
--- 
a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java
+++ 
b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java
@@ -193,13 +193,13 @@ public interface GcsOptions extends 
ApplicationNameOptions, GcpOptions, Pipeline
 
   /**
    * Maximum number of files rewritten concurrently when copying or moving 
files. If unset, a
-   * default of 32 is used.
+   * default of 128 is used.
    *
    * <p>Only applicable to GcsUtil V2, which is enabled with the {@code 
use_gcsutil_v2} experiment.
    */
   @Description(
       "Maximum number of files rewritten concurrently when copying or moving 
files in GCS."
-          + " If unset, defaults to 32. Only applicable to GcsUtil V2 (enabled 
with the"
+          + " If unset, defaults to 128. Only applicable to GcsUtil V2 
(enabled with the"
           + " use_gcsutil_v2 experiment).")
   @Nullable Integer getGcsMaxConcurrentRewrites();
 
diff --git 
a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java
 
b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java
index 863bc9030e9..c0f40a9aec4 100644
--- 
a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java
+++ 
b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java
@@ -56,9 +56,11 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.Closeable;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InterruptedIOException;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
 import java.nio.channels.SeekableByteChannel;
 import java.nio.channels.WritableByteChannel;
 import java.nio.file.AccessDeniedException;
@@ -159,8 +161,12 @@ class GcsUtilV2 {
   /**
    * Default maximum number of files rewritten concurrently by {@link #copy} 
and {@link #move}, used
    * when {@link GcsOptions#getGcsMaxConcurrentRewrites} is unset.
+   *
+   * <p>A copy or move never uses more threads than it has files, so this only 
caps large ones, such
+   * as the temp-to-final renames that finish a write of thousands of shards. 
There 128 brought the
+   * rename time close to {@code GcsUtilV1}'s batched rewrites without any 
request being throttled.
    */
-  private static final int DEFAULT_MAX_CONCURRENT_REWRITES = 32;
+  private static final int DEFAULT_MAX_CONCURRENT_REWRITES = 128;
 
   /**
    * How long {@link #copy} and {@link #move} wait for in-flight rewrites to 
finish after a failure
@@ -168,6 +174,18 @@ class GcsUtilV2 {
    */
   private static final long REWRITE_TERMINATION_TIMEOUT_MINUTES = 5;
 
+  /**
+   * Starts the resumable upload sessions of {@link #create} in the 
background. Unbounded, so that a
+   * burst of creates is never queued behind one another, and daemon, so that 
an abandoned upload
+   * cannot keep the JVM alive.
+   */
+  private static final ExecutorService UPLOAD_SESSION_EXECUTOR =
+      Executors.newCachedThreadPool(
+          new ThreadFactoryBuilder()
+              .setDaemon(true)
+              .setNameFormat("gcsutil-v2-upload-session-%d")
+              .build());
+
   /** Maximum number of files rewritten concurrently by {@link #copy} and 
{@link #move}. */
   private final int maxConcurrentRewrites;
 
@@ -192,6 +210,13 @@ class GcsUtilV2 {
       storageOptionsBuilder.setHost(rootUrlOf(endpoint));
     }
 
+    // Use the process-wide shared HTTP transport rather than letting each 
client create its own, so
+    // a new client reuses established connections instead of opening new ones 
on its first request.
+    storageOptionsBuilder.setTransportOptions(
+        StorageOptions.getDefaultHttpTransportOptions().toBuilder()
+            .setHttpTransportFactory(Transport::getTransport)
+            .build());
+
     storage = storageOptionsBuilder.build().getService();
     uploadBufferSizeBytes = gcsOptions.getGcsUploadBufferSizeBytes();
     this.gcsCountersOptions =
@@ -994,20 +1019,67 @@ class GcsUtilV2 {
     }
   }
 
-  /** A bridge that allows a GCS WriteChannel to behave as a 
WritableByteChannel. */
+  /**
+   * A bridge that allows a GCS WriteChannel to behave as a 
WritableByteChannel.
+   *
+   * <p>The WriteChannel is opened in the background, because java-storage 
starts the resumable
+   * upload session with a blocking request when it opens one. Waiting for it 
on the first write, or
+   * on close, rather than in {@link #create} lets that request overlap with 
the caller's own work,
+   * as gcsio does for {@code GcsUtilV1}. A failure to start the session 
surfaces there instead.
+   */
   private static class GcsWritableByteChannel implements WritableByteChannel {
-    private final WriteChannel writer;
+    private final Future<WriteChannel> pendingWriter;
     private final GcsPath gcsPath;
+    private boolean closed = false;
+    // Set when the upload session could not be started (it failed, or waiting 
for it was
+    // interrupted). There is then nothing to finalize, and the failure has 
already been reported.
+    private boolean sessionFailed = false;
 
-    GcsWritableByteChannel(WriteChannel writer, GcsPath gcsPath) {
-      this.writer = writer;
+    GcsWritableByteChannel(Future<WriteChannel> pendingWriter, GcsPath 
gcsPath) {
+      this.pendingWriter = pendingWriter;
       this.gcsPath = gcsPath;
     }
 
+    /** Waits for the upload session to start and returns its channel. */
+    private WriteChannel writer() throws IOException {
+      try {
+        return pendingWriter.get();
+      } catch (InterruptedException e) {
+        sessionFailed = true;
+        Thread.currentThread().interrupt();
+        pendingWriter.cancel(true);
+        throw new InterruptedIOException("Interrupted while starting the 
upload to " + gcsPath);
+      } catch (CancellationException e) {
+        // Only cancelled above, by an earlier interrupted wait, which 
abandoned the upload.
+        sessionFailed = true;
+        throw new InterruptedIOException(
+            "The upload to " + gcsPath + " was abandoned after an earlier 
interrupt");
+      } catch (ExecutionException e) {
+        sessionFailed = true;
+        Throwable cause = e.getCause();
+        if (cause instanceof StorageException) {
+          throw translateStorageException(gcsPath, (StorageException) cause);
+        }
+        if (cause instanceof IOException) {
+          throw (IOException) cause;
+        }
+        if (cause instanceof RuntimeException) {
+          throw (RuntimeException) cause;
+        }
+        if (cause instanceof Error) {
+          throw (Error) cause;
+        }
+        throw new IOException("Failed to start the upload to " + gcsPath, 
cause);
+      }
+    }
+
     @Override
     public int write(ByteBuffer src) throws IOException {
+      if (closed) {
+        throw new ClosedChannelException();
+      }
       try {
-        return writer.write(src);
+        return writer().write(src);
       } catch (StorageException e) {
         throw translateStorageException(gcsPath, e);
       }
@@ -1015,14 +1087,23 @@ class GcsUtilV2 {
 
     @Override
     public boolean isOpen() {
-      return writer.isOpen();
+      return !closed;
     }
 
     @Override
     public void close() throws IOException {
+      if (closed) {
+        return;
+      }
+      closed = true;
+      if (sessionFailed) {
+        // An earlier write already reported that the session could not be 
started, and there is
+        // nothing to finalize.
+        return;
+      }
       // The upload is finalized here, so this is where a failed precondition 
surfaces.
       try {
-        writer.close();
+        writer().close();
       } catch (StorageException e) {
         throw translateStorageException(gcsPath, e);
       }
@@ -1058,19 +1139,33 @@ class GcsUtilV2 {
           
writeOptionList.add(BlobWriteOption.generationMatch(blob.getGeneration()));
         }
       }
-      // Open a WriteChannel from the storage service
-      WriteChannel writer =
-          client.writer(blobInfo, writeOptionList.toArray(new 
BlobWriteOption[0]));
+      // Open a WriteChannel from the storage service. Opening one starts the 
resumable upload
+      // session with a blocking request, so it is done in the background and 
the returned channel
+      // waits for it on its first write or on close (see 
GcsWritableByteChannel).
+      BlobWriteOption[] writeOptionArray = writeOptionList.toArray(new 
BlobWriteOption[0]);
       Integer uploadBufferSizeBytes =
           options.getUploadBufferSizeBytes() != null
               ? options.getUploadBufferSizeBytes()
               : this.uploadBufferSizeBytes;
-      writer.setChunkSize(
-          uploadBufferSizeBytes != null ? uploadBufferSizeBytes : 
DEFAULT_UPLOAD_CHUNK_SIZE_BYTES);
+      int chunkSize =
+          uploadBufferSizeBytes != null ? uploadBufferSizeBytes : 
DEFAULT_UPLOAD_CHUNK_SIZE_BYTES;
+      Future<WriteChannel> pendingWriter =
+          UPLOAD_SESSION_EXECUTOR.submit(
+              () -> {
+                try {
+                  WriteChannel writer = client.writer(blobInfo, 
writeOptionArray);
+                  writer.setChunkSize(chunkSize);
+                  serviceCallMetric.call("ok");
+                  return writer;
+                } catch (StorageException e) {
+                  serviceCallMetric.call(e.getCode());
+                  throw e;
+                }
+              });
 
-      serviceCallMetric.call("ok");
       // Return the bridge wrapper
-      return wrapInCounting(new GcsWritableByteChannel(writer, path), 
path.getBucket(), container);
+      return wrapInCounting(
+          new GcsWritableByteChannel(pendingWriter, path), path.getBucket(), 
container);
 
     } catch (StorageException e) {
       serviceCallMetric.call(e.getCode());
diff --git 
a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java
 
b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java
index 0654f974444..9335ab6f56e 100644
--- 
a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java
+++ 
b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java
@@ -58,9 +58,11 @@ import com.google.cloud.storage.StorageOptions;
 import java.io.ByteArrayOutputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InterruptedIOException;
 import java.math.BigInteger;
 import java.nio.ByteBuffer;
 import java.nio.channels.Channels;
+import java.nio.channels.ClosedChannelException;
 import java.nio.channels.SeekableByteChannel;
 import java.nio.channels.WritableByteChannel;
 import java.nio.file.AccessDeniedException;
@@ -71,6 +73,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 import 
org.apache.beam.repackaged.core.org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
 import org.apache.beam.runners.core.metrics.CounterCell;
 import org.apache.beam.runners.core.metrics.GcpResourceIdentifiers;
@@ -560,6 +563,175 @@ public class GcsUtilV2Test {
     assertSame(preconditionFailed, thrown.getCause());
   }
 
+  /**
+   * java-storage starts the resumable upload session with a blocking request 
when it opens a
+   * WriteChannel. create() must leave that to a background thread, as gcsio 
does for V1, so that
+   * the caller does not wait for it.
+   */
+  @Test
+  public void testV2CreateStartsUploadSessionInBackground() throws Exception {
+    WriteChannel writer = Mockito.mock(WriteChannel.class);
+    CountDownLatch release = new CountDownLatch(1);
+    AtomicReference<Thread> openingThread = new AtomicReference<>();
+    Storage storage = Mockito.mock(Storage.class);
+    when(storage.writer(any(BlobInfo.class), any()))
+        .thenAnswer(
+            invocation -> {
+              openingThread.set(Thread.currentThread());
+              if (!release.await(10, TimeUnit.SECONDS)) {
+                throw new AssertionError("the upload session was never 
released");
+              }
+              return writer;
+            });
+    GcsUtil gcsUtil = gcsUtilWithV2Storage(storage);
+
+    WritableByteChannel channel =
+        gcsUtil.create(
+            GcsPath.fromComponents("testbucket", "testobject"),
+            CreateOptions.builder().setExpectFileToNotExist(true).build());
+    assertTrue(channel.isOpen());
+
+    release.countDown();
+    channel.write(ByteBuffer.wrap(new byte[] {1, 2, 3}));
+    channel.close();
+
+    assertNotSame(Thread.currentThread(), openingThread.get());
+    verify(writer).write(any(ByteBuffer.class));
+    verify(writer).close();
+    assertFalse(channel.isOpen());
+  }
+
+  /**
+   * A failure to start the upload session surfaces on the first write, as an 
IOException. Close is
+   * then a no-op, and later writes report the closed channel.
+   */
+  @Test
+  public void testV2CreateReportsUploadSessionFailureOnWrite() throws 
IOException {
+    Storage storage = Mockito.mock(Storage.class);
+    when(storage.writer(any(BlobInfo.class), any()))
+        .thenThrow(new StorageException(403, "Forbidden"));
+    GcsUtil gcsUtil = gcsUtilWithV2Storage(storage);
+
+    WritableByteChannel channel =
+        gcsUtil.create(
+            GcsPath.fromComponents("testbucket", "testobject"),
+            CreateOptions.builder().setExpectFileToNotExist(true).build());
+
+    assertThrows(AccessDeniedException.class, () -> 
channel.write(ByteBuffer.wrap(new byte[] {1})));
+    // The failure was already reported and there is no session to finalize, 
so close is a no-op.
+    channel.close();
+    assertFalse(channel.isOpen());
+    assertThrows(
+        ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new 
byte[] {1})));
+  }
+
+  /** A java-storage client whose upload sessions do not start until {@code 
release} fires. */
+  private static Storage storageWithBlockedUploadSession(
+      CountDownLatch release, WriteChannel writer) {
+    Storage storage = Mockito.mock(Storage.class);
+    when(storage.writer(any(BlobInfo.class), any()))
+        .thenAnswer(
+            invocation -> {
+              release.await();
+              return writer;
+            });
+    return storage;
+  }
+
+  private static WritableByteChannel createTestObject(GcsUtil gcsUtil) throws 
IOException {
+    return gcsUtil.create(
+        GcsPath.fromComponents("testbucket", "testobject"),
+        CreateOptions.builder().setExpectFileToNotExist(true).build());
+  }
+
+  /** A closed channel rejects writes itself, rather than relying on the 
underlying channel. */
+  @Test
+  public void testV2WriteAfterCloseThrowsClosedChannelException() throws 
IOException {
+    WriteChannel writer = Mockito.mock(WriteChannel.class);
+    WritableByteChannel channel = 
createTestObject(gcsUtilWithV2Storage(storageWritingTo(writer)));
+
+    channel.close();
+
+    assertThrows(
+        ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new 
byte[] {1})));
+    verify(writer, never()).write(any(ByteBuffer.class));
+  }
+
+  /**
+   * When the session fails to start and nothing was written, close reports 
the failure. A later
+   * write must then report the closed channel, not the session failure again.
+   */
+  @Test
+  public void testV2WriteAfterFailedCloseThrowsClosedChannelException() throws 
IOException {
+    Storage storage = Mockito.mock(Storage.class);
+    when(storage.writer(any(BlobInfo.class), any()))
+        .thenThrow(new StorageException(403, "Forbidden"));
+    WritableByteChannel channel = 
createTestObject(gcsUtilWithV2Storage(storage));
+
+    assertThrows(AccessDeniedException.class, channel::close);
+
+    assertFalse(channel.isOpen());
+    assertThrows(
+        ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new 
byte[] {1})));
+  }
+
+  /**
+   * An interrupt while the first write waits for the session abandons the 
upload. Later writes
+   * report that as an IOException rather than leaking a 
CancellationException, and close is a
+   * no-op.
+   */
+  @Test
+  public void testV2WriteInterruptedWhileStartingSession() throws IOException {
+    CountDownLatch release = new CountDownLatch(1);
+    WriteChannel writer = Mockito.mock(WriteChannel.class);
+    WritableByteChannel channel =
+        
createTestObject(gcsUtilWithV2Storage(storageWithBlockedUploadSession(release, 
writer)));
+    try {
+      Thread.currentThread().interrupt();
+      assertThrows(
+          InterruptedIOException.class, () -> 
channel.write(ByteBuffer.wrap(new byte[] {1})));
+      // The interrupt is preserved for the caller.
+      assertTrue(Thread.interrupted());
+
+      assertThrows(
+          InterruptedIOException.class, () -> 
channel.write(ByteBuffer.wrap(new byte[] {1})));
+      channel.close();
+      assertFalse(channel.isOpen());
+      verify(writer, never()).write(any(ByteBuffer.class));
+      verify(writer, never()).close();
+    } finally {
+      Thread.interrupted();
+      release.countDown();
+    }
+  }
+
+  /**
+   * An interrupt while close waits for the session of an empty file is 
reported by close. A later
+   * write must then report the closed channel.
+   */
+  @Test
+  public void testV2CloseInterruptedWhileStartingSession() throws IOException {
+    CountDownLatch release = new CountDownLatch(1);
+    WriteChannel writer = Mockito.mock(WriteChannel.class);
+    WritableByteChannel channel =
+        
createTestObject(gcsUtilWithV2Storage(storageWithBlockedUploadSession(release, 
writer)));
+    try {
+      Thread.currentThread().interrupt();
+      assertThrows(InterruptedIOException.class, channel::close);
+      assertTrue(Thread.interrupted());
+
+      assertFalse(channel.isOpen());
+      assertThrows(
+          ClosedChannelException.class, () -> 
channel.write(ByteBuffer.wrap(new byte[] {1})));
+      // A second close is a no-op.
+      channel.close();
+      verify(writer, never()).close();
+    } finally {
+      Thread.interrupted();
+      release.countDown();
+    }
+  }
+
   /** Mirrors {@link GcsUtilV1Test#testBucketDoesNotExist} for V2. */
   @Test
   public void testV2BucketAccessibleIsFalseWhenBucketDoesNotExist() throws 
IOException {
@@ -724,18 +896,21 @@ public class GcsUtilV2Test {
   @Test
   public void testV2CreatePreconditions() throws IOException {
     GcsPath path = GcsPath.fromComponents("testbucket", "testobject");
+    // Each channel is closed so that its upload session, started in the 
background, has been
+    // opened before it is verified.
 
     // Expected not to exist: no lookup is made.
     Storage expectedMissing = 
storageWritingTo(Mockito.mock(WriteChannel.class));
     gcsUtilWithV2Storage(expectedMissing)
-        .create(path, 
CreateOptions.builder().setExpectFileToNotExist(true).build());
+        .create(path, 
CreateOptions.builder().setExpectFileToNotExist(true).build())
+        .close();
     verify(expectedMissing).writer(any(BlobInfo.class), 
Mockito.eq(BlobWriteOption.doesNotExist()));
     verify(expectedMissing, never()).get(anyString(), anyString(), any());
 
     // Looked up and missing. An unstubbed get() returns null, which is how 
java-storage reports a
     // missing object.
     Storage missing = storageWritingTo(Mockito.mock(WriteChannel.class));
-    gcsUtilWithV2Storage(missing).create(path, 
CreateOptions.builder().build());
+    gcsUtilWithV2Storage(missing).create(path, 
CreateOptions.builder().build()).close();
     verify(missing).writer(any(BlobInfo.class), 
Mockito.eq(BlobWriteOption.doesNotExist()));
 
     // Looked up and found.
@@ -743,7 +918,7 @@ public class GcsUtilV2Test {
     Blob blob = Mockito.mock(Blob.class);
     when(blob.getGeneration()).thenReturn(42L);
     when(existing.get(Mockito.eq("testbucket"), Mockito.eq("testobject"), 
any())).thenReturn(blob);
-    gcsUtilWithV2Storage(existing).create(path, 
CreateOptions.builder().build());
+    gcsUtilWithV2Storage(existing).create(path, 
CreateOptions.builder().build()).close();
     verify(existing).writer(any(BlobInfo.class), 
Mockito.eq(BlobWriteOption.generationMatch(42L)));
   }
 
@@ -765,21 +940,23 @@ public class GcsUtilV2Test {
     pipelineWithSize.setGcsUploadBufferSizeBytes(2048);
 
     WriteChannel writer = Mockito.mock(WriteChannel.class);
-    gcsUtilWithV2Storage(pipelineWithSize, 
storageWritingTo(writer)).create(path, withSize);
+    gcsUtilWithV2Storage(pipelineWithSize, 
storageWritingTo(writer)).create(path, withSize).close();
     verify(writer).setChunkSize(1024);
 
     writer = Mockito.mock(WriteChannel.class);
-    gcsUtilWithV2Storage(pipelineWithSize, 
storageWritingTo(writer)).create(path, withoutSize);
+    gcsUtilWithV2Storage(pipelineWithSize, storageWritingTo(writer))
+        .create(path, withoutSize)
+        .close();
     verify(writer).setChunkSize(2048);
 
     writer = Mockito.mock(WriteChannel.class);
-    gcsUtilWithV2Storage(storageWritingTo(writer)).create(path, withoutSize);
+    gcsUtilWithV2Storage(storageWritingTo(writer)).create(path, 
withoutSize).close();
     verify(writer).setChunkSize(GcsUtilV2.DEFAULT_UPLOAD_CHUNK_SIZE_BYTES);
   }
 
   /** Mirrors {@link GcsUtilV1Test#testGCSWriteMetricsIsSet}. */
   @Test
-  public void testV2WriteMetricsIsSet() {
+  public void testV2WriteMetricsIsSet() throws IOException {
     MetricsContainerImpl container = new MetricsContainerImpl(null);
     MetricsEnvironment.setProcessWideContainer(container);
     Storage storage = Mockito.mock(Storage.class);
@@ -788,9 +965,10 @@ public class GcsUtilV2Test {
     GcsUtil gcsUtil = gcsUtilWithV2Storage(storage);
     GcsPath path = GcsPath.fromComponents("testbucket", "testobject");
 
-    assertThrows(
-        AccessDeniedException.class,
-        () -> gcsUtil.create(path, 
CreateOptions.builder().setExpectFileToNotExist(true).build()));
+    // The upload session is started in the background, so its failure 
surfaces on close.
+    WritableByteChannel channel =
+        gcsUtil.create(path, 
CreateOptions.builder().setExpectFileToNotExist(true).build());
+    assertThrows(AccessDeniedException.class, channel::close);
 
     verifyMetricWasSet("my_project", "testbucket", "GcsInsert", 
"permission_denied", 1);
     verifyMetricWasSet("my_project", "testbucket", "GcsInsert", "ok", 0);

Reply via email to