Repository: googleapis/java-storage
Pull request #3026 (2025-04-09 18:35:02)

chore: make ReadProjectionConfigs#asFuture{Bytes,ByteString} public
Additional javadocs, test added to ensure getting a future after a session has closed results in an exception.




Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncSessionClosedException.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncSessionClosedException.java
new file mode 100644
index 00000000..982796f1
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncSessionClosedException.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025 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.storage;
+
+import com.google.api.core.BetaApi;
+
+/**
+ * Root exception for async tasks which fail due to a session being closed.
+ *
+ * @see BlobReadSession
+ */
+@BetaApi
+public final class AsyncSessionClosedException extends RuntimeException {
+  AsyncSessionClosedException(String msg) {
+    super(msg);
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobReadSession.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobReadSession.java
index 41c80bb5..eb424fcd 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobReadSession.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobReadSession.java
@@ -62,7 +62,7 @@ public interface BlobReadSession extends AutoCloseable, Closeable {
    *
    * <p>If a projection is not fully consumed/resolved it will be transitioned to a failed state.
    *
-   * <p>This method MUST be closed to ensure cleanup of any inflight buffers, and to avoid a memory
+   * <p>This method MUST be called to ensure cleanup of any inflight buffers, and to avoid a memory
    * leak.
    *
    * @since 2.51.0 This new api is in preview and is subject to breaking changes.
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionImpl.java
index bf387357..a4d54a17 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionImpl.java
@@ -83,7 +83,7 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
   public <Projection> Projection readAs(ReadProjectionConfig<Projection> config) {
     lock.lock();
     try {
-      checkState(open, "stream already closed");
+      checkState(open, "Session already closed");
       switch (config.getType()) {
         case STREAM_READ:
           long readId = state.newReadId();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionStream.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionStream.java
index d44d6c41..9e0e01f2 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionStream.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ObjectReadSessionStream.java
@@ -47,7 +47,6 @@ import com.google.storage.v2.ReadRangeError;
 import io.grpc.Status.Code;
 import io.grpc.StatusRuntimeException;
 import java.io.IOException;
-import java.nio.channels.AsynchronousCloseException;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
@@ -124,7 +123,7 @@ final class ObjectReadSessionStream
     }
     int updatedLeaseCount = openLeases.decrementAndGet();
     if (updatedLeaseCount == 0) {
-      AsynchronousCloseException cause = new AsynchronousCloseException();
+      AsyncSessionClosedException cause = new AsyncSessionClosedException("Session already closed");
       ApiFuture<?> f = failAll(() -> new StorageException(0, "Parent stream shutdown", cause));
       return ApiFutures.transformAsync(f, ignore -> ApiFutures.immediateFuture(null), executor);
     } else {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureBytes.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureBytes.java
index dcea76b9..722b01bc 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureBytes.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureBytes.java
@@ -37,7 +37,8 @@ import javax.annotation.concurrent.Immutable;
  */
 @BetaApi
 @Immutable
-final class ReadAsFutureBytes extends BaseConfig<ApiFuture<byte[]>, AccumulatingRead<byte[]>> {
+public final class ReadAsFutureBytes
+    extends BaseConfig<ApiFuture<byte[]>, AccumulatingRead<byte[]>> {
 
   static final ReadAsFutureBytes INSTANCE =
       new ReadAsFutureBytes(RangeSpec.all(), Hasher.enabled());
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadProjectionConfigs.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadProjectionConfigs.java
index 212519d7..1156cf7e 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadProjectionConfigs.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadProjectionConfigs.java
@@ -83,7 +83,7 @@ public final class ReadProjectionConfigs {
    * @since 2.51.0 This new api is in preview and is subject to breaking changes.
    */
   @BetaApi
-  static ReadAsFutureBytes asFutureBytes() {
+  public static ReadAsFutureBytes asFutureBytes() {
     return ReadAsFutureBytes.INSTANCE;
   }
 
@@ -104,7 +104,7 @@ public final class ReadProjectionConfigs {
    * @since 2.51.0 This new api is in preview and is subject to breaking changes.
    */
   @BetaApi
-  static ReadAsFutureByteString asFutureByteString() {
+  public static ReadAsFutureByteString asFutureByteString() {
     return ReadAsFutureByteString.INSTANCE;
   }
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ZeroCopySupport.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ZeroCopySupport.java
index eaeede2e..8ac0ed61 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ZeroCopySupport.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ZeroCopySupport.java
@@ -16,22 +16,45 @@
 
 package com.google.cloud.storage;
 
-import com.google.api.core.InternalApi;
+import com.google.api.core.BetaApi;
 import com.google.api.core.InternalExtensionOnly;
 import com.google.protobuf.ByteString;
 import java.io.Closeable;
 import java.io.IOException;
 
-@InternalApi
-@InternalExtensionOnly
-interface ZeroCopySupport {
+/**
+ * Public components which exist to support zero-copy data access
+ *
+ * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public abstract class ZeroCopySupport {
+
+  private ZeroCopySupport() {}
 
-  @InternalApi
+  /**
+   * Represents an object that can be accessed as a {@link ByteString}, but has a lifecycle that
+   * requires being explicitly closed in order to free up resources.
+   *
+   * <p>Instances of this class should be used in a try-with-resources to ensure they are released.
+   *
+   * <pre>{@code
+   * try (DisposableByteString disposableByteString = ...) {
+   *   System.out.println(disposableByteString.byteString().size());
+   * }
+   * }</pre>
+   *
+   * @see ReadProjectionConfigs#asFutureByteString()
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+   */
+  @BetaApi
   @InternalExtensionOnly
-  interface DisposableByteString extends AutoCloseable, Closeable {
+  public interface DisposableByteString extends AutoCloseable, Closeable {
 
+    /** Get the ByteString representation of the underlying resources */
     ByteString byteString();
 
+    /** Signal the underlying resources that they can be released. */
     @Override
     void close() throws IOException;
   }


Test patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureByteString.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureByteString.java
index fe283a34..aa7eee53 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureByteString.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ReadAsFutureByteString.java
@@ -42,7 +42,7 @@ import javax.annotation.concurrent.Immutable;
  */
 @BetaApi
 @Immutable
-final class ReadAsFutureByteString
+public final class ReadAsFutureByteString
     extends BaseConfig<ApiFuture<DisposableByteString>, AccumulatingRead<DisposableByteString>> {
 
   static final ReadAsFutureByteString INSTANCE =
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionFakeTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionFakeTest.java
index fdc60c7a..f270c1d2 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionFakeTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionFakeTest.java
@@ -78,7 +78,6 @@ import io.grpc.protobuf.ProtoUtils;
 import io.grpc.stub.StreamObserver;
 import java.io.ByteArrayOutputStream;
 import java.nio.ByteBuffer;
-import java.nio.channels.AsynchronousCloseException;
 import java.nio.channels.Channels;
 import java.nio.channels.ScatteringByteChannel;
 import java.nio.channels.WritableByteChannel;
@@ -1045,11 +1044,13 @@ public final class ITObjectReadSessionFakeTest {
               assertAll(
                   () -> assertThat(se2).isNotSameInstanceAs(se3),
                   () ->
-                      assertThat(se2).hasCauseThat().isInstanceOf(AsynchronousCloseException.class),
+                      assertThat(se2)
+                          .hasCauseThat()
+                          .isInstanceOf(AsyncSessionClosedException.class),
                   () ->
                       assertThat(se3)
                           .hasCauseThat()
-                          .isInstanceOf(AsynchronousCloseException.class));
+                          .isInstanceOf(AsyncSessionClosedException.class));
             });
       }
     }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionTest.java
index 08691300..044095fc 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITObjectReadSessionTest.java
@@ -101,6 +101,23 @@ public final class ITObjectReadSessionTest {
     }
   }
 
+  @Test
+  public void attemptingToGetFutureOutSizeSessionFails() throws Throwable {
+    ObjectAndContent obj512KiB = objectsFixture.getObj512KiB();
+    BlobId blobId = obj512KiB.getInfo().getBlobId();
+
+    ApiFuture<byte[]> future;
+    try (BlobReadSession session = storage.blobReadSession(blobId).get(30, TimeUnit.SECONDS)) {
+      future = session.readAs(ReadProjectionConfigs.asFutureBytes());
+    }
+
+    ExecutionException ee =
+        assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS));
+
+    assertThat(ee).hasCauseThat().isInstanceOf(StorageException.class);
+    assertThat(ee).hasCauseThat().hasCauseThat().isInstanceOf(AsyncSessionClosedException.class);
+  }
+
   @Test
   public void lotsOfBytes() throws Exception {
     ChecksummedTestContent testContent =
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionStreamTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionStreamTest.java
index 38790341..75fec2cb 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionStreamTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionStreamTest.java
@@ -49,7 +49,6 @@ import com.google.storage.v2.BucketName;
 import com.google.storage.v2.Object;
 import java.io.IOException;
 import java.nio.ByteBuffer;
-import java.nio.channels.AsynchronousCloseException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -219,19 +218,19 @@ public final class ObjectReadSessionStreamTest {
           Throwable t1 = read1.fail.get(2, TimeUnit.SECONDS);
           // t1.printStackTrace(System.out);
           assertThat(t1).isInstanceOf(StorageException.class);
-          assertThat(t1).hasCauseThat().isInstanceOf(AsynchronousCloseException.class);
+          assertThat(t1).hasCauseThat().isInstanceOf(AsyncSessionClosedException.class);
         },
         () -> {
           Throwable t2 = read2.fail.get(2, TimeUnit.SECONDS);
           // t2.printStackTrace(System.err);
           assertThat(t2).isInstanceOf(StorageException.class);
-          assertThat(t2).hasCauseThat().isInstanceOf(AsynchronousCloseException.class);
+          assertThat(t2).hasCauseThat().isInstanceOf(AsyncSessionClosedException.class);
         },
         () -> {
           Throwable t3 = read3.fail.get(2, TimeUnit.SECONDS);
           // t3.printStackTrace(System.out);
           assertThat(t3).isInstanceOf(StorageException.class);
-          assertThat(t3).hasCauseThat().isInstanceOf(AsynchronousCloseException.class);
+          assertThat(t3).hasCauseThat().isInstanceOf(AsyncSessionClosedException.class);
         });
   }
 
@@ -312,7 +311,7 @@ public final class ObjectReadSessionStreamTest {
           () -> assertThat(bytes2Close.get()).isTrue(),
           () -> assertThat(read1.acceptingBytes()).isFalse(),
           () -> assertThat(se).hasMessageThat().contains("Parent stream shutdown"),
-          () -> assertThat(se).hasCauseThat().isInstanceOf(AsynchronousCloseException.class));
+          () -> assertThat(se).hasCauseThat().isInstanceOf(AsyncSessionClosedException.class));
     }
   }