Repository: googleapis/java-storage
Pull request #3036 (2025-04-14 22:04:28)

chore: refactor BlobAppendableUpload, so it follows the lifecycle of BlobWriteSession
This allows access to the resulting BlobInfo, even if not finalized. The returned BlobInfo will have generation and a client side updated `size` matching the number of persisted bytes the client knows has been ack'd by GCS.

Add closeAction option to BlobAppendableUploadConfig allowing configuration of finalization on close behavior.



Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUpload.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUpload.java
index e0795b39..b79a2909 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUpload.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUpload.java
@@ -19,10 +19,12 @@ package com.google.cloud.storage;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.BetaApi;
 import com.google.api.core.InternalExtensionOnly;
+import com.google.cloud.storage.BlobAppendableUploadConfig.CloseAction;
 import com.google.cloud.storage.Storage.BlobWriteOption;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.channels.WritableByteChannel;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Interface representing those methods which can be used to write to and interact with an
@@ -32,37 +34,120 @@ import java.nio.channels.WritableByteChannel;
  */
 @BetaApi
 @InternalExtensionOnly
-public interface BlobAppendableUpload extends AutoCloseable, WritableByteChannel {
+public interface BlobAppendableUpload extends BlobWriteSession {
 
   /**
-   * Write some bytes to the appendable session. Whether a flush happens will depend on how many
-   * bytes have been written prior, how many bytes are being written now and what {@link
-   * BlobAppendableUploadConfig} was provided when creating the {@link BlobAppendableUpload}.
+   * Open the {@link AppendableUploadWriteableByteChannel AppendableUploadWriteableByteChannel} for
+   * this session.
    *
-   * <p>This method can block the invoking thread in order to ensure written bytes are acknowledged
-   * by Google Cloud Storage.
+   * <p>A session may only be {@code open}ed once. If multiple calls to open are made, an illegal
+   * state exception will be thrown
    *
-   * @see Storage#blobAppendableUpload(BlobInfo, BlobAppendableUploadConfig, BlobWriteOption...)
+   * <p>The returned {@code AppendableUploadWriteableByteChannel} can throw IOExceptions from any of
+   * its usual methods. Any {@link IOException} thrown can have a cause of a {@link
+   * StorageException}. However, not all {@code IOExceptions} will have {@code StorageException}s.
+   *
+   * @throws IOException When creating the {@link AppendableUploadWriteableByteChannel} if an
+   *     unrecoverable underlying IOException occurs it can be rethrown
+   * @throws IllegalStateException if open is called more than once
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
    */
   @Override
-  int write(ByteBuffer src) throws IOException;
+  AppendableUploadWriteableByteChannel open() throws IOException;
 
   /**
-   * Close this instance to further {@link #write(ByteBuffer)}ing. This will close any underlying
-   * stream and release any releasable resources once out of scope.
+   * Return an {@link ApiFuture}{@code <BlobInfo>} which will represent the state of the object in
+   * Google Cloud Storage.
+   *
+   * <p>This future will not resolve until:
+   *
+   * <ol>
+   *   <li>The object is successfully finalized in Google Cloud Storage by calling {@link
+   *       AppendableUploadWriteableByteChannel#finalizeAndClose()
+   *       AppendableUploadWriteableByteChannel#finalizeAndClose()}
+   *   <li>This session is detached from the upload without finalizing by calling {@link
+   *       AppendableUploadWriteableByteChannel#closeWithoutFinalizing()
+   *       AppendableUploadWriteableByteChannel#closeWithoutFinalizing()}
+   *   <li>The session is closed by calling {@link AppendableUploadWriteableByteChannel#close()
+   *       AppendableUploadWriteableByteChannel#close()}
+   *   <li>A terminal failure occurs, the terminal failure will become the exception result
+   * </ol>
+   *
+   * <p><i>NOTICE:</i> Some fields may not be populated unless finalization has completed.
+   *
+   * <p>If a terminal failure is encountered, calling either {@link ApiFuture#get()} or {@link
+   * ApiFuture#get(long, TimeUnit)} will result in an {@link
+   * java.util.concurrent.ExecutionException} with the cause.
    *
-   * <p>{@link #finalizeUpload()} can be called after this method, but it will not carry any bytes
-   * with it.
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
    */
   @Override
-  void close() throws IOException;
+  ApiFuture<BlobInfo> getResult();
 
   /**
-   * Finalize the appendable upload, close any underlying stream and release any releasable
-   * resources once out of scope.
+   * The {@link WritableByteChannel} returned from {@link BlobAppendableUpload#open()}.
    *
-   * <p>Once this method is called, and returns no more writes to the object will be allowed by GCS.
+   * <p>This interface allows writing bytes to an Appendable Upload, and provides methods to close
+   * this channel -- optionally finalizing the upload.
+   *
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
    */
   @BetaApi
-  ApiFuture<BlobInfo> finalizeUpload() throws IOException;
+  @InternalExtensionOnly
+  interface AppendableUploadWriteableByteChannel extends WritableByteChannel {
+
+    /**
+     * Finalize the upload and close this instance to further {@link #write(ByteBuffer)}ing. This
+     * will close any underlying stream and release any releasable resources once out of scope.
+     *
+     * <p>Once this method is called, and returns no more writes to the object will be allowed by
+     * GCS.
+     *
+     * <p>This method and {@link #close()} are mutually exclusive. If one of the other methods are
+     * called before this method, this method will be a no-op.
+     *
+     * @see Storage#blobAppendableUpload(BlobInfo, BlobAppendableUploadConfig, BlobWriteOption...)
+     * @see BlobAppendableUploadConfig.CloseAction#FINALIZE_WHEN_CLOSING
+     * @see BlobAppendableUploadConfig#getCloseAction()
+     * @see BlobAppendableUploadConfig#withCloseAction(CloseAction)
+     * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    void finalizeAndClose() throws IOException;
+
+    /**
+     * Close this instance to further {@link #write(ByteBuffer)}ing without finalizing the upload.
+     * This will close any underlying stream and release any releasable resources once out of scope.
+     *
+     * <p>This method, {@link AppendableUploadWriteableByteChannel#finalizeAndClose()} and {@link
+     * AppendableUploadWriteableByteChannel#close()} are mutually exclusive. If one of the other
+     * methods are called before this method, this method will be a no-op.
+     *
+     * @see Storage#blobAppendableUpload(BlobInfo, BlobAppendableUploadConfig, BlobWriteOption...)
+     * @see BlobAppendableUploadConfig.CloseAction#CLOSE_WITHOUT_FINALIZING
+     * @see BlobAppendableUploadConfig#getCloseAction()
+     * @see BlobAppendableUploadConfig#withCloseAction(CloseAction)
+     * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    void closeWithoutFinalizing() throws IOException;
+
+    /**
+     * Close this instance to further {@link #write(ByteBuffer)}ing.
+     *
+     * <p>Whether the upload is finalized during this depends on the {@link
+     * BlobAppendableUploadConfig#getCloseAction()} provided to create the {@link
+     * BlobAppendableUpload}. If {@link BlobAppendableUploadConfig#getCloseAction()}{@code ==
+     * }{@link CloseAction#FINALIZE_WHEN_CLOSING}, {@link #finalizeAndClose()} will be called. If
+     * {@link BlobAppendableUploadConfig#getCloseAction()}{@code == }{@link
+     * CloseAction#CLOSE_WITHOUT_FINALIZING}, {@link #closeWithoutFinalizing()} will be called.
+     *
+     * @see Storage#blobAppendableUpload(BlobInfo, BlobAppendableUploadConfig, BlobWriteOption...)
+     * @see BlobAppendableUploadConfig#getCloseAction()
+     * @see BlobAppendableUploadConfig#withCloseAction(CloseAction)
+     * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    void close() throws IOException;
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadConfig.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadConfig.java
index 6000f503..ae95356d 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadConfig.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadConfig.java
@@ -19,10 +19,21 @@ package com.google.cloud.storage;
 import static com.google.cloud.storage.ByteSizeConstants._256KiB;
 import static java.util.Objects.requireNonNull;
 
+import com.google.api.core.ApiFutures;
 import com.google.api.core.BetaApi;
 import com.google.api.core.InternalApi;
+import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
+import com.google.api.gax.rpc.AbortedException;
+import com.google.api.gax.rpc.ApiException;
+import com.google.cloud.storage.BlobAppendableUpload.AppendableUploadWriteableByteChannel;
+import com.google.cloud.storage.BlobAppendableUploadImpl.AppendableObjectBufferedWritableByteChannel;
 import com.google.cloud.storage.Storage.BlobWriteOption;
 import com.google.cloud.storage.TransportCompatibility.Transport;
+import com.google.cloud.storage.UnifiedOpts.ObjectTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.Opts;
+import com.google.storage.v2.BidiWriteObjectRequest;
+import com.google.storage.v2.BidiWriteObjectResponse;
+import com.google.storage.v2.Object;
 import javax.annotation.concurrent.Immutable;
 
 /**
@@ -39,14 +50,20 @@ import javax.annotation.concurrent.Immutable;
 public final class BlobAppendableUploadConfig {
 
   private static final BlobAppendableUploadConfig INSTANCE =
-      new BlobAppendableUploadConfig(FlushPolicy.minFlushSize(_256KiB), Hasher.enabled());
+      new BlobAppendableUploadConfig(
+          FlushPolicy.minFlushSize(_256KiB),
+          Hasher.enabled(),
+          CloseAction.CLOSE_WITHOUT_FINALIZING);
 
   private final FlushPolicy flushPolicy;
   private final Hasher hasher;
+  private final CloseAction closeAction;
 
-  private BlobAppendableUploadConfig(FlushPolicy flushPolicy, Hasher hasher) {
+  private BlobAppendableUploadConfig(
+      FlushPolicy flushPolicy, Hasher hasher, CloseAction closeAction) {
     this.flushPolicy = flushPolicy;
     this.hasher = hasher;
+    this.closeAction = closeAction;
   }
 
   /**
@@ -77,7 +94,37 @@ public final class BlobAppendableUploadConfig {
     if (this.flushPolicy.equals(flushPolicy)) {
       return this;
     }
-    return new BlobAppendableUploadConfig(flushPolicy, hasher);
+    return new BlobAppendableUploadConfig(flushPolicy, hasher, closeAction);
+  }
+
+  /**
+   * The {@link CloseAction} which will dictate the behavior of {@link
+   * AppendableUploadWriteableByteChannel#close()}.
+   *
+   * <p><i>Default:</i> {@link CloseAction#CLOSE_WITHOUT_FINALIZING}
+   *
+   * @see #withCloseAction(CloseAction)
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+   */
+  @BetaApi
+  public CloseAction getCloseAction() {
+    return closeAction;
+  }
+
+  /**
+   * Return an instance with the {@code CloseAction} set to be the specified value. <i>Default:</i>
+   * {@link CloseAction#CLOSE_WITHOUT_FINALIZING}
+   *
+   * @see #getCloseAction()
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+   */
+  @BetaApi
+  public BlobAppendableUploadConfig withCloseAction(CloseAction closeAction) {
+    requireNonNull(closeAction, "closeAction must be non null");
+    if (this.closeAction == closeAction) {
+      return this;
+    }
+    return new BlobAppendableUploadConfig(flushPolicy, hasher, closeAction);
   }
 
   /**
@@ -108,7 +155,8 @@ public final class BlobAppendableUploadConfig {
     } else if (!enabled && Hasher.noop().equals(hasher)) {
       return this;
     }
-    return new BlobAppendableUploadConfig(flushPolicy, enabled ? Hasher.enabled() : Hasher.noop());
+    return new BlobAppendableUploadConfig(
+        flushPolicy, enabled ? Hasher.enabled() : Hasher.noop(), closeAction);
   }
 
   /** Never to be made public until {@link Hasher} is public */
@@ -125,6 +173,7 @@ public final class BlobAppendableUploadConfig {
    * <pre>{@code
    * BlobAppendableUploadConfig.of()
    *   .withFlushPolicy(FlushPolicy.minFlushSize(256 * 1024))
+   *   .withCloseAction(CloseAction.CLOSE_WITHOUT_FINALIZING)
    * }</pre>
    *
    * @since 2.51.0 This new api is in preview and is subject to breaking changes.
@@ -134,4 +183,89 @@ public final class BlobAppendableUploadConfig {
   public static BlobAppendableUploadConfig of() {
     return INSTANCE;
   }
+
+  /**
+   * Enum providing the possible actions which can be taken during the {@link
+   * AppendableUploadWriteableByteChannel#close()} call.
+   *
+   * @see AppendableUploadWriteableByteChannel#close()
+   * @see BlobAppendableUploadConfig#withCloseAction(CloseAction)
+   * @see BlobAppendableUploadConfig#getCloseAction()
+   * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+   */
+  @BetaApi
+  public enum CloseAction {
+    /**
+     * Designate that when {@link AppendableUploadWriteableByteChannel#close()} is called, the
+     * appendable upload should be finalized.
+     *
+     * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+     * @see AppendableUploadWriteableByteChannel#finalizeAndClose()
+     */
+    @BetaApi
+    FINALIZE_WHEN_CLOSING,
+    /**
+     * Designate that when {@link AppendableUploadWriteableByteChannel#close()} is called, the
+     * appendable upload should NOT be finalized, allowing for takeover by another session or
+     * client.
+     *
+     * @since 2.51.0 This new api is in preview and is subject to breaking changes.
+     * @see AppendableUploadWriteableByteChannel#closeWithoutFinalizing()
+     */
+    @BetaApi
+    CLOSE_WITHOUT_FINALIZING
+  }
+
+  BlobAppendableUpload create(GrpcStorageImpl storage, BlobInfo info, Opts<ObjectTargetOpt> opts) {
+    boolean takeOver = info.getGeneration() != null;
+    BidiWriteObjectRequest req =
+        takeOver
+            ? storage.getBidiWriteObjectRequestForTakeover(info, opts)
+            : storage.getBidiWriteObjectRequest(info, opts);
+
+    BidiAppendableWrite baw = new BidiAppendableWrite(req, takeOver);
+
+    WritableByteChannelSession<AppendableObjectBufferedWritableByteChannel, BidiWriteObjectResponse>
+        build =
+            ResumableMedia.gapic()
+                .write()
+                .bidiByteChannel(storage.storageClient.bidiWriteObjectCallable())
+                .setHasher(this.getHasher())
+                .setByteStringStrategy(ByteStringStrategy.copy())
+                .appendable()
+                .withRetryConfig(
+                    storage.retrier.withAlg(
+                        new BasicResultRetryAlgorithm<Object>() {
+                          @Override
+                          public boolean shouldRetry(
+                              Throwable previousThrowable, Object previousResponse) {
+                            // TODO: remove this later once the redirects are not handled by the
+                            // retry loop
+                            ApiException apiEx = null;
+                            if (previousThrowable instanceof StorageException) {
+                              StorageException se = (StorageException) previousThrowable;
+                              Throwable cause = se.getCause();
+                              if (cause instanceof ApiException) {
+                                apiEx = (ApiException) cause;
+                              }
+                            }
+                            if (apiEx instanceof AbortedException) {
+                              return true;
+                            }
+                            return storage
+                                .retryAlgorithmManager
+                                .idempotent()
+                                .shouldRetry(previousThrowable, null);
+                          }
+                        }))
+                .buffered(this.getFlushPolicy())
+                .setStartAsync(ApiFutures.immediateFuture(baw))
+                .setGetCallable(storage.storageClient.getObjectCallable())
+                .setFinalizeOnClose(this.closeAction == CloseAction.FINALIZE_WHEN_CLOSING)
+                .build();
+
+    return new BlobAppendableUploadImpl(
+        new DefaultBlobWriteSessionConfig.DecoratedWritableByteChannelSession<>(
+            build, BidiBlobWriteSessionConfig.Factory.WRITE_OBJECT_RESPONSE_BLOB_INFO_DECODER));
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadImpl.java
index b9c88c0b..cedfbcba 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobAppendableUploadImpl.java
@@ -19,60 +19,35 @@ package com.google.cloud.storage;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.BetaApi;
 import com.google.cloud.storage.BufferedWritableByteChannelSession.BufferedWritableByteChannel;
+import com.google.common.base.Preconditions;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.concurrent.locks.ReentrantLock;
 
 @BetaApi
 final class BlobAppendableUploadImpl implements BlobAppendableUpload {
-  private final AppendableObjectBufferedWritableByteChannel channel;
-  private final ApiFuture<BlobInfo> result;
-
-  private BlobAppendableUploadImpl(BlobInfo blob, BlobWriteSession session, boolean takeover)
-      throws IOException {
-    channel = (AppendableObjectBufferedWritableByteChannel) (session.open());
-    result = session.getResult();
-    if (takeover) {
-      channel.startTakeoverStream();
-    }
-  }
-
-  static BlobAppendableUpload createNewAppendableBlob(BlobInfo blob, BlobWriteSession session)
-      throws IOException {
-    return new BlobAppendableUploadImpl(blob, session, false);
-  }
-
-  static BlobAppendableUpload resumeAppendableUpload(BlobInfo blob, BlobWriteSession session)
-      throws IOException {
-    return new BlobAppendableUploadImpl(blob, session, true);
-  }
-
-  void startTakeoverStream() {
-    channel.startTakeoverStream();
-  }
-
-  @BetaApi
-  public ApiFuture<BlobInfo> finalizeUpload() throws IOException {
-    channel.finalizeWrite();
-    close();
-    return result;
+  private final WritableByteChannelSession<AppendableObjectBufferedWritableByteChannel, BlobInfo>
+      delegate;
+  private boolean open;
+
+  BlobAppendableUploadImpl(
+      WritableByteChannelSession<AppendableObjectBufferedWritableByteChannel, BlobInfo> delegate) {
+    this.delegate = delegate;
+    this.open = false;
   }
 
   @Override
-  public int write(ByteBuffer buffer) throws IOException {
-    return channel.write(buffer);
-  }
-
-  @Override
-  public boolean isOpen() {
-    return channel.isOpen();
+  public AppendableUploadWriteableByteChannel open() throws IOException {
+    synchronized (this) {
+      Preconditions.checkState(!open, "already open");
+      open = true;
+      return delegate.open();
+    }
   }
 
   @Override
-  public void close() throws IOException {
-    if (channel.isOpen()) {
-      channel.close();
-    }
+  public ApiFuture<BlobInfo> getResult() {
+    return delegate.getResult();
   }
 
   /**
@@ -85,16 +60,20 @@ final class BlobAppendableUploadImpl implements BlobAppendableUpload {
    * wrap it over this one.
    */
   static final class AppendableObjectBufferedWritableByteChannel
-      implements BufferedWritableByteChannel {
+      implements BufferedWritableByteChannel,
+          BlobAppendableUpload.AppendableUploadWriteableByteChannel {
     private final BufferedWritableByteChannel buffered;
     private final GapicBidiUnbufferedAppendableWritableByteChannel unbuffered;
+    private final boolean finalizeOnClose;
     private final ReentrantLock lock;
 
     AppendableObjectBufferedWritableByteChannel(
         BufferedWritableByteChannel buffered,
-        GapicBidiUnbufferedAppendableWritableByteChannel unbuffered) {
+        GapicBidiUnbufferedAppendableWritableByteChannel unbuffered,
+        boolean finalizeOnClose) {
       this.buffered = buffered;
       this.unbuffered = unbuffered;
+      this.finalizeOnClose = finalizeOnClose;
       lock = new ReentrantLock();
     }
 
@@ -129,27 +108,38 @@ final class BlobAppendableUploadImpl implements BlobAppendableUpload {
     }
 
     @Override
-    public void close() throws IOException {
+    public void finalizeAndClose() throws IOException {
       lock.lock();
       try {
-        buffered.close();
+        if (buffered.isOpen()) {
+          buffered.flush();
+          unbuffered.finalizeWrite();
+          buffered.close();
+        }
       } finally {
         lock.unlock();
       }
     }
 
-    public void finalizeWrite() throws IOException {
+    @Override
+    public void closeWithoutFinalizing() throws IOException {
       lock.lock();
       try {
-        buffered.flush();
-        unbuffered.finalizeWrite();
+        if (buffered.isOpen()) {
+          buffered.close();
+        }
       } finally {
         lock.unlock();
       }
     }
 
-    void startTakeoverStream() {
-      unbuffered.startAppendableTakeoverStream();
+    @Override
+    public void close() throws IOException {
+      if (finalizeOnClose) {
+        finalizeAndClose();
+      } else {
+        closeWithoutFinalizing();
+      }
     }
   }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ChannelSession.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ChannelSession.java
index 92964cf0..25ff1e40 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ChannelSession.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ChannelSession.java
@@ -37,7 +37,7 @@ class ChannelSession<StartT, ResultT, ChannelT> {
 
   private volatile ApiFuture<ChannelT> channelFuture;
 
-  private ChannelSession(
+  ChannelSession(
       ApiFuture<StartT> startFuture, BiFunction<StartT, SettableApiFuture<ResultT>, ChannelT> f) {
     this.startFuture = startFuture;
     this.resultFuture = SettableApiFuture.create();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiUnbufferedAppendableWriteableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiUnbufferedAppendableWriteableByteChannel.java
index f60b1fe7..b5e907ba 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiUnbufferedAppendableWriteableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiUnbufferedAppendableWriteableByteChannel.java
@@ -376,7 +376,7 @@ final class GapicBidiUnbufferedAppendableWritableByteChannel
   private class BidiObserver implements ApiStreamObserver<BidiWriteObjectResponse> {
 
     private final Semaphore sem;
-    private volatile BidiWriteObjectResponse last;
+    private volatile BidiWriteObjectResponse lastResponseWithResource;
     private volatile StorageException clientDetectedError;
     private volatile RuntimeException previousError;
 
@@ -504,14 +504,18 @@ final class GapicBidiUnbufferedAppendableWritableByteChannel
 
     @Override
     public void onCompleted() {
-      if (last != null) {
-        resultFuture.set(last);
+      if (lastResponseWithResource != null) {
+        BidiWriteObjectResponse.Builder withSize = lastResponseWithResource.toBuilder();
+        withSize.getResourceBuilder().setSize(writeCtx.getConfirmedBytes().longValue());
+        resultFuture.set(withSize.build());
       }
       sem.release();
     }
 
     private void ok(BidiWriteObjectResponse value) {
-      last = value;
+      if (value.hasResource()) {
+        lastResponseWithResource = value;
+      }
       first = false;
       sem.release();
     }
@@ -558,7 +562,7 @@ final class GapicBidiUnbufferedAppendableWritableByteChannel
 
     public void reset() {
       sem.drainPermits();
-      last = null;
+      lastResponseWithResource = null;
       clientDetectedError = null;
       previousError = null;
     }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiWritableByteChannelSessionBuilder.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiWritableByteChannelSessionBuilder.java
index 02cca773..1e32da70 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiWritableByteChannelSessionBuilder.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicBidiWritableByteChannelSessionBuilder.java
@@ -22,6 +22,7 @@ import com.google.api.core.ApiFuture;
 import com.google.api.core.SettableApiFuture;
 import com.google.api.gax.rpc.BidiStreamingCallable;
 import com.google.api.gax.rpc.UnaryCallable;
+import com.google.cloud.storage.BlobAppendableUploadImpl.AppendableObjectBufferedWritableByteChannel;
 import com.google.cloud.storage.ChannelSession.BufferedWriteSession;
 import com.google.cloud.storage.Retrying.RetrierWithAlg;
 import com.google.cloud.storage.UnbufferedWritableByteChannelSession.UnbufferedWritableByteChannel;
@@ -184,6 +185,7 @@ final class GapicBidiWritableByteChannelSessionBuilder {
 
     final class BufferedAppendableUploadBuilder {
       private final FlushPolicy flushPolicy;
+      private boolean finalizeOnClose;
       private ApiFuture<BidiAppendableWrite> start;
       private UnaryCallable<GetObjectRequest, Object> get;
 
@@ -191,6 +193,11 @@ final class GapicBidiWritableByteChannelSessionBuilder {
         this.flushPolicy = flushPolicy;
       }
 
+      BufferedAppendableUploadBuilder setFinalizeOnClose(boolean finalizeOnClose) {
+        this.finalizeOnClose = finalizeOnClose;
+        return this;
+      }
+
       /**
        * Set the Future which will contain the AppendableWrite information necessary to open the
        * Write stream.
@@ -206,7 +213,9 @@ final class GapicBidiWritableByteChannelSessionBuilder {
         return this;
       }
 
-      BufferedWritableByteChannelSession<BidiWriteObjectResponse> build() {
+      WritableByteChannelSession<
+              AppendableObjectBufferedWritableByteChannel, BidiWriteObjectResponse>
+          build() {
         // it is theoretically possible that the setter methods for the following variables could
         // be called again between when this method is invoked and the resulting function is
         // invoked.
@@ -218,7 +227,8 @@ final class GapicBidiWritableByteChannelSessionBuilder {
         RetrierWithAlg boundRetrier = retrier;
         UnaryCallable<GetObjectRequest, Object> boundGet =
             requireNonNull(get, "get must be non null");
-        return new BufferedWriteSession<>(
+        boolean boundFinalizeOnClose = finalizeOnClose;
+        return new AppendableSession(
             requireNonNull(start, "start must be non null"),
             ((BiFunction<
                         BidiAppendableWrite,
@@ -235,10 +245,32 @@ final class GapicBidiWritableByteChannelSessionBuilder {
                             new BidiWriteCtx<>(start),
                             Retrying::newCallContext))
                 .andThen(
-                    c ->
-                        new BlobAppendableUploadImpl.AppendableObjectBufferedWritableByteChannel(
-                            flushPolicy.createBufferedChannel(c), c)));
+                    c -> {
+                      boolean takeOver =
+                          c.getWriteCtx().getRequestFactory().getReq().hasAppendObjectSpec();
+                      if (takeOver) {
+                        c.startAppendableTakeoverStream();
+                      }
+                      return new AppendableObjectBufferedWritableByteChannel(
+                          flushPolicy.createBufferedChannel(c), c, boundFinalizeOnClose);
+                    }));
       }
     }
   }
+
+  private static final class AppendableSession
+      extends ChannelSession<
+          BidiAppendableWrite, BidiWriteObjectResponse, AppendableObjectBufferedWritableByteChannel>
+      implements WritableByteChannelSession<
+          AppendableObjectBufferedWritableByteChannel, BidiWriteObjectResponse> {
+    private AppendableSession(
+        ApiFuture<BidiAppendableWrite> startFuture,
+        BiFunction<
+                BidiAppendableWrite,
+                SettableApiFuture<BidiWriteObjectResponse>,
+                AppendableObjectBufferedWritableByteChannel>
+            f) {
+      super(startFuture, f);
+    }
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index 653c8409..9a01926c 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -35,9 +35,7 @@ import com.google.api.core.BetaApi;
 import com.google.api.gax.grpc.GrpcCallContext;
 import com.google.api.gax.paging.AbstractPage;
 import com.google.api.gax.paging.Page;
-import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
 import com.google.api.gax.retrying.ResultRetryAlgorithm;
-import com.google.api.gax.rpc.AbortedException;
 import com.google.api.gax.rpc.ApiException;
 import com.google.api.gax.rpc.ApiExceptions;
 import com.google.api.gax.rpc.ClientStreamingCallable;
@@ -87,7 +85,6 @@ import com.google.storage.v2.AppendObjectSpec;
 import com.google.storage.v2.BidiReadObjectRequest;
 import com.google.storage.v2.BidiReadObjectSpec;
 import com.google.storage.v2.BidiWriteObjectRequest;
-import com.google.storage.v2.BidiWriteObjectResponse;
 import com.google.storage.v2.BucketAccessControl;
 import com.google.storage.v2.ComposeObjectRequest;
 import com.google.storage.v2.ComposeObjectRequest.SourceObject;
@@ -1424,60 +1421,9 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
   @BetaApi
   @Override
   public BlobAppendableUpload blobAppendableUpload(
-      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options)
-      throws IOException {
-    boolean takeOver = blobInfo.getGeneration() != null;
+      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options) {
     Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
-    BidiWriteObjectRequest req =
-        takeOver
-            ? getBidiWriteObjectRequestForTakeover(blobInfo, opts)
-            : getBidiWriteObjectRequest(blobInfo, opts);
-    BidiAppendableWrite baw = new BidiAppendableWrite(req, takeOver);
-    ApiFuture<BidiAppendableWrite> startAppendableWrite = ApiFutures.immediateFuture(baw);
-    WritableByteChannelSession<BufferedWritableByteChannel, BidiWriteObjectResponse> build =
-        ResumableMedia.gapic()
-            .write()
-            .bidiByteChannel(storageClient.bidiWriteObjectCallable())
-            .setHasher(uploadConfig.getHasher())
-            .setByteStringStrategy(ByteStringStrategy.copy())
-            .appendable()
-            .withRetryConfig(
-                retrier.withAlg(
-                    new BasicResultRetryAlgorithm<Object>() {
-                      @Override
-                      public boolean shouldRetry(
-                          Throwable previousThrowable, Object previousResponse) {
-                        // TODO: remove this later once the redirects are not handled by the retry
-                        // loop
-                        ApiException apiEx = null;
-                        if (previousThrowable instanceof StorageException) {
-                          StorageException se = (StorageException) previousThrowable;
-                          Throwable cause = se.getCause();
-                          if (cause instanceof ApiException) {
-                            apiEx = (ApiException) cause;
-                          }
-                        }
-                        if (apiEx instanceof AbortedException) {
-                          return true;
-                        }
-                        return retryAlgorithmManager
-                            .idempotent()
-                            .shouldRetry(previousThrowable, null);
-                      }
-                    }))
-            .buffered(uploadConfig.getFlushPolicy())
-            .setStartAsync(startAppendableWrite)
-            .setGetCallable(storageClient.getObjectCallable())
-            .build();
-    DefaultBlobWriteSessionConfig.DecoratedWritableByteChannelSession<
-            BufferedWritableByteChannel, BidiWriteObjectResponse>
-        dec =
-            new DefaultBlobWriteSessionConfig.DecoratedWritableByteChannelSession<>(
-                build, BidiBlobWriteSessionConfig.Factory.WRITE_OBJECT_RESPONSE_BLOB_INFO_DECODER);
-    BlobWriteSession session = BlobWriteSessions.of(dec);
-    return takeOver
-        ? BlobAppendableUploadImpl.resumeAppendableUpload(blobInfo, session)
-        : BlobAppendableUploadImpl.createNewAppendableBlob(blobInfo, session);
+    return uploadConfig.create(this, blobInfo, opts);
   }
 
   @Override
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 a4d54a17..2998f4d8 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
@@ -32,12 +32,13 @@ import java.io.InterruptedIOException;
 import java.util.ArrayList;
 import java.util.IdentityHashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map.Entry;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.locks.ReentrantLock;
-import org.checkerframework.checker.lock.qual.GuardedBy;
+import java.util.function.BiFunction;
 
 final class ObjectReadSessionImpl implements ObjectReadSession {
 
@@ -49,12 +50,8 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
   private final Object resource;
   private final RetryContextProvider retryContextProvider;
 
-  @GuardedBy("this.lock")
-  private final IdentityHashMap<ObjectReadSessionStream, ObjectReadSessionState> children;
+  private final ConcurrentIdentityMap<ObjectReadSessionStream, ObjectReadSessionState> children;
 
-  private final ReentrantLock lock;
-
-  @GuardedBy("this.lock")
   private volatile boolean open;
 
   ObjectReadSessionImpl(
@@ -69,8 +66,7 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
     this.state = state;
     this.resource = state.getMetadata();
     this.retryContextProvider = retryContextProvider;
-    this.children = new IdentityHashMap<>();
-    this.lock = new ReentrantLock();
+    this.children = new ConcurrentIdentityMap<>();
     this.open = true;
   }
 
@@ -81,45 +77,35 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
 
   @Override
   public <Projection> Projection readAs(ReadProjectionConfig<Projection> config) {
-    lock.lock();
-    try {
-      checkState(open, "Session already closed");
-      switch (config.getType()) {
-        case STREAM_READ:
-          long readId = state.newReadId();
-          ObjectReadSessionStreamRead<Projection> read =
-              config.cast().newRead(readId, retryContextProvider.create());
-          registerReadInState(readId, read);
-          return read.project();
-        case SESSION_USER:
-          return config.project(this, IOAutoCloseable.noOp());
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  Locale.US,
-                  "Broken java enum %s value=%s",
-                  ProjectionType.class.getName(),
-                  config.getType().name()));
-      }
-    } finally {
-      lock.unlock();
+    checkState(open, "Session already closed");
+    switch (config.getType()) {
+      case STREAM_READ:
+        long readId = state.newReadId();
+        ObjectReadSessionStreamRead<Projection> read =
+            config.cast().newRead(readId, retryContextProvider.create());
+        registerReadInState(readId, read);
+        return read.project();
+      case SESSION_USER:
+        return config.project(this, IOAutoCloseable.noOp());
+      default:
+        throw new IllegalStateException(
+            String.format(
+                Locale.US,
+                "Broken java enum %s value=%s",
+                ProjectionType.class.getName(),
+                config.getType().name()));
     }
   }
 
   @Override
   public void close() throws IOException {
-    open = false;
-    lock.lock();
     try {
-      Iterator<Entry<ObjectReadSessionStream, ObjectReadSessionState>> it =
-          children.entrySet().iterator();
-      ArrayList<ApiFuture<Void>> closing = new ArrayList<>(children.size());
-      while (it.hasNext()) {
-        Entry<ObjectReadSessionStream, ObjectReadSessionState> next = it.next();
-        ObjectReadSessionStream subStream = next.getKey();
-        it.remove();
-        closing.add(subStream.closeAsync());
+      if (!open) {
+        return;
       }
+      open = false;
+      List<ApiFuture<Void>> closing =
+          children.drainEntries((subStream, subStreamState) -> subStream.closeAsync());
       stream.close();
       ApiFutures.allAsList(closing).get();
     } catch (ExecutionException e) {
@@ -127,8 +113,6 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
     } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
       throw new InterruptedIOException();
-    } finally {
-      lock.unlock();
     }
   }
 
@@ -152,4 +136,53 @@ final class ObjectReadSessionImpl implements ObjectReadSession {
       newStream.send(request);
     }
   }
+
+  @VisibleForTesting
+  static final class ConcurrentIdentityMap<K, V> {
+    private final ReentrantLock lock;
+    private final IdentityHashMap<K, V> children;
+
+    @VisibleForTesting
+    ConcurrentIdentityMap() {
+      lock = new ReentrantLock();
+      children = new IdentityHashMap<>();
+    }
+
+    public void put(K key, V value) {
+      lock.lock();
+      try {
+        children.put(key, value);
+      } finally {
+        lock.unlock();
+      }
+    }
+
+    public void remove(K key) {
+      lock.lock();
+      try {
+        children.remove(key);
+      } finally {
+        lock.unlock();
+      }
+    }
+
+    public <R> ArrayList<R> drainEntries(BiFunction<K, V, R> f) {
+      lock.lock();
+      try {
+        Iterator<Entry<K, V>> it = children.entrySet().iterator();
+        ArrayList<R> results = new ArrayList<>(children.size());
+        while (it.hasNext()) {
+          Entry<K, V> entry = it.next();
+          K key = entry.getKey();
+          V value = entry.getValue();
+          it.remove();
+          R r = f.apply(key, value);
+          results.add(r);
+        }
+        return results;
+      } finally {
+        lock.unlock();
+      }
+    }
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
index 6e665c4d..67336310 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
@@ -1506,19 +1506,25 @@ final class OtelStorageDecorator implements Storage {
 
   @Override
   public BlobAppendableUpload blobAppendableUpload(
-      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options)
-      throws IOException {
-    Span span = tracer.spanBuilder("appendableBlobUpload").startSpan();
+      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options) {
+
+    Span span =
+        tracer
+            .spanBuilder("appendableBlobUpload")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
     try (Scope ignore = span.makeCurrent()) {
 
       return new OtelDecoratingBlobAppendableUpload(
-          delegate.blobAppendableUpload(blobInfo, uploadConfig, options), span, Context.current());
+          delegate.blobAppendableUpload(blobInfo, uploadConfig, options));
 
     } catch (Throwable t) {
       span.recordException(t);
       span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
       span.end();
       throw t;
+    } finally {
+      span.end();
     }
   }
 
@@ -2104,57 +2110,98 @@ final class OtelStorageDecorator implements Storage {
 
   final class OtelDecoratingBlobAppendableUpload implements BlobAppendableUpload {
     private final BlobAppendableUpload delegate;
-    private final Span openSpan;
-    private final Context openContext;
+    private final Tracer tracer;
 
-    private OtelDecoratingBlobAppendableUpload(
-        BlobAppendableUpload delegate, Span openSpan, Context openContext) {
+    private OtelDecoratingBlobAppendableUpload(BlobAppendableUpload delegate) {
       this.delegate = delegate;
-      this.openSpan = openSpan;
-      this.openContext = openContext;
-    }
-
-    @Override
-    public int write(ByteBuffer src) throws IOException {
-      return delegate.write(src);
+      this.tracer =
+          TracerDecorator.decorate(
+              Context.current(),
+              otel,
+              OtelStorageDecorator.this.baseAttributes,
+              BlobAppendableUpload.class.getName() + "/");
     }
 
     @Override
-    public void close() throws IOException {
-      try {
-        delegate.close();
+    public AppendableUploadWriteableByteChannel open() throws IOException {
+      Span openSpan = tracer.spanBuilder("open").startSpan();
+      try (Scope ignore = openSpan.makeCurrent()) {
+        AppendableUploadWriteableByteChannel delegate = this.delegate.open();
+        return new OtelDecoratingAppendableUploadWriteableByteChannel(delegate, openSpan);
       } catch (Throwable t) {
         openSpan.recordException(t);
         openSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
         throw t;
-      } finally {
-        openSpan.end();
       }
     }
 
     @Override
-    @BetaApi
-    public ApiFuture<BlobInfo> finalizeUpload() throws IOException {
-      Span span =
-          tracer
-              .spanBuilder("appendableBlobUpload/finalizeUpload")
-              .setParent(openContext)
-              .startSpan();
-      try (Scope ignore = span.makeCurrent()) {
-        return delegate.finalizeUpload();
-      } catch (Throwable t) {
-        span.recordException(t);
-        span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
-        throw t;
-      } finally {
-        span.end();
-        openSpan.end();
-      }
+    public ApiFuture<BlobInfo> getResult() {
+      return delegate.getResult();
     }
 
-    @Override
-    public boolean isOpen() {
-      return delegate.isOpen();
+    private final class OtelDecoratingAppendableUploadWriteableByteChannel
+        implements AppendableUploadWriteableByteChannel {
+      private final AppendableUploadWriteableByteChannel delegate;
+      private final Span openSpan;
+
+      private OtelDecoratingAppendableUploadWriteableByteChannel(
+          AppendableUploadWriteableByteChannel delegate, Span openSpan) {
+        this.delegate = delegate;
+        this.openSpan = openSpan;
+      }
+
+      @Override
+      @BetaApi
+      public void finalizeAndClose() throws IOException {
+        try {
+          delegate.finalizeAndClose();
+        } catch (IOException | RuntimeException e) {
+          openSpan.recordException(e);
+          openSpan.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
+          throw e;
+        } finally {
+          openSpan.end();
+        }
+      }
+
+      @Override
+      @BetaApi
+      public void closeWithoutFinalizing() throws IOException {
+        try {
+          delegate.closeWithoutFinalizing();
+        } catch (IOException | RuntimeException e) {
+          openSpan.recordException(e);
+          openSpan.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
+          throw e;
+        } finally {
+          openSpan.end();
+        }
+      }
+
+      @Override
+      @BetaApi
+      public void close() throws IOException {
+        try {
+          delegate.close();
+        } catch (IOException | RuntimeException e) {
+          openSpan.recordException(e);
+          openSpan.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
+          throw e;
+        } finally {
+          openSpan.end();
+        }
+      }
+
+      @Override
+      public int write(ByteBuffer src) throws IOException {
+        return delegate.write(src);
+      }
+
+      @Override
+      public boolean isOpen() {
+        return delegate.isOpen();
+      }
     }
   }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
index 07cf7400..64f7d4a5 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
@@ -5901,21 +5901,21 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
    * BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build();
    * ReadableByteChannel readableByteChannel = ...;
    *
-   * try (
-   *   BlobAppendableUpload blobAppendableUpload = storage.blobAppendableUpload(
+   * BlobAppendableUpload uploadSession = storage.blobAppendableUpload(
    *     blobInfo,
    *     BlobAppendableUploadConfig.of()
-   *   )
-   * ) {
-   * // copy all bytes
-   * ByteStreams.copy(readableByteChannel, blobAppendableUpload);
+   * );
+   * try (AppendableUploadWriteableByteChannel channel = uploadSession.open()) {
+   *   // copy all bytes
+   *   ByteStreams.copy(readableByteChannel, channel);
+   *   channel.finalizeAndClose();
+   * } catch (IOException ex) {
+   *   // handle IOException
+   * }
+   *
    * // get the resulting object metadata
-   * ApiFuture<BlobInfo> resultFuture = blobAppendableUpload.finalizeUpload();
+   * ApiFuture<BlobInfo> resultFuture = uploadSession.getResult();
    * BlobInfo gen1 = resultFuture.get();
-   *
-   * } catch (IOException e) {
-   * // handle IOException
-   * }
    * }</pre>
    *
    * @param blobInfo blobInfo to create
@@ -5927,8 +5927,7 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
   @BetaApi
   @TransportCompatibility({Transport.GRPC})
   default BlobAppendableUpload blobAppendableUpload(
-      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options)
-      throws IOException {
+      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options) {
     return throwGrpcOnly(
         fmtMethodName("appendableBlobUpload", BlobId.class, BlobWriteOption.class));
   }


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadFakeTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadFakeTest.java
index 6153e025..5528e004 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadFakeTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadFakeTest.java
@@ -23,6 +23,8 @@ import static org.junit.Assert.assertThrows;
 import com.google.api.core.SettableApiFuture;
 import com.google.api.gax.grpc.GrpcCallContext;
 import com.google.api.gax.rpc.AbortedException;
+import com.google.cloud.storage.BlobAppendableUpload.AppendableUploadWriteableByteChannel;
+import com.google.cloud.storage.BlobAppendableUploadConfig.CloseAction;
 import com.google.cloud.storage.it.ChecksummedTestContent;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
@@ -49,6 +51,7 @@ import io.grpc.StatusRuntimeException;
 import io.grpc.protobuf.ProtoUtils;
 import io.grpc.stub.StreamObserver;
 import java.nio.ByteBuffer;
+import java.time.Duration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.UUID;
@@ -90,7 +93,8 @@ public class ITAppendableUploadFakeTest {
   private static final BlobAppendableUploadConfig UPLOAD_CONFIG =
       BlobAppendableUploadConfig.of()
           .withFlushPolicy(FlushPolicy.maxFlushSize(5))
-          .withCrc32cValidationEnabled(false);
+          .withCrc32cValidationEnabled(false)
+          .withCloseAction(CloseAction.FINALIZE_WHEN_CLOSING);
 
   /**
    *
@@ -205,8 +209,10 @@ public class ITAppendableUploadFakeTest {
       BlobId id = BlobId.of("b", "o");
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
-      b.write(ByteBuffer.wrap(content.getBytes()));
-      BlobInfo bi = b.finalizeUpload().get(5, TimeUnit.SECONDS);
+      try (AppendableUploadWriteableByteChannel channel = b.open()) {
+        channel.write(ByteBuffer.wrap(content.getBytes()));
+      }
+      BlobInfo bi = b.getResult().get(5, TimeUnit.SECONDS);
       assertThat(bi.getSize()).isEqualTo(10);
     }
   }
@@ -264,18 +270,36 @@ public class ITAppendableUploadFakeTest {
                 respond -> respond.onError(statusRuntimeException)));
 
     try (FakeServer fakeServer = FakeServer.of(fake);
-        Storage storage = fakeServer.getGrpcStorageOptions().toBuilder().build().getService()) {
+        Storage storage =
+            fakeServer
+                .getGrpcStorageOptions()
+                .toBuilder()
+                .setRetrySettings(
+                    fakeServer
+                        .getGrpcStorageOptions()
+                        .getRetrySettings()
+                        .toBuilder()
+                        .setRetryDelayMultiplier(1.0)
+                        .setInitialRetryDelayDuration(Duration.ofMillis(10))
+                        .build())
+                .build()
+                .getService()) {
 
       BlobId id = BlobId.of("b", "o");
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
-      StorageException e =
-          assertThrows(
-              StorageException.class,
-              () -> {
-                b.write(ByteBuffer.wrap("ABCDE".getBytes()));
-              });
-      assertThat(e).hasCauseThat().isInstanceOf(AbortedException.class);
+      AppendableUploadWriteableByteChannel channel = b.open();
+      try {
+        StorageException e =
+            assertThrows(
+                StorageException.class,
+                () -> {
+                  channel.write(ByteBuffer.wrap("ABCDE".getBytes()));
+                });
+        assertThat(e).hasCauseThat().isInstanceOf(AbortedException.class);
+      } finally {
+        channel.close();
+      }
     }
   }
 
@@ -384,8 +408,10 @@ public class ITAppendableUploadFakeTest {
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
       ChecksummedTestContent content = ChecksummedTestContent.of(ALL_OBJECT_BYTES, 0, 10);
-      b.write(ByteBuffer.wrap(content.getBytes()));
-      BlobInfo bi = b.finalizeUpload().get(5, TimeUnit.SECONDS);
+      try (AppendableUploadWriteableByteChannel channel = b.open()) {
+        channel.write(ByteBuffer.wrap(content.getBytes()));
+      }
+      BlobInfo bi = b.getResult().get(5, TimeUnit.SECONDS);
       assertThat(bi.getSize()).isEqualTo(10);
     }
   }
@@ -497,8 +523,10 @@ public class ITAppendableUploadFakeTest {
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
       ChecksummedTestContent content = ChecksummedTestContent.of(ALL_OBJECT_BYTES, 0, 10);
-      b.write(ByteBuffer.wrap(content.getBytes()));
-      BlobInfo bi = b.finalizeUpload().get(5, TimeUnit.SECONDS);
+      try (AppendableUploadWriteableByteChannel channel = b.open()) {
+        channel.write(ByteBuffer.wrap(content.getBytes()));
+      }
+      BlobInfo bi = b.getResult().get(5, TimeUnit.SECONDS);
       assertThat(bi.getSize()).isEqualTo(10);
     }
   }
@@ -1432,8 +1460,10 @@ public class ITAppendableUploadFakeTest {
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
       ChecksummedTestContent content = ChecksummedTestContent.of(ALL_OBJECT_BYTES, 10, 10);
-      b.write(ByteBuffer.wrap(content.getBytes()));
-      BlobInfo bi = b.finalizeUpload().get(5, TimeUnit.SECONDS);
+      try (AppendableUploadWriteableByteChannel channel = b.open()) {
+        channel.write(ByteBuffer.wrap(content.getBytes()));
+      }
+      BlobInfo bi = b.getResult().get(5, TimeUnit.SECONDS);
       assertThat(bi.getSize()).isEqualTo(20);
     }
   }
@@ -1485,8 +1515,10 @@ public class ITAppendableUploadFakeTest {
       BlobAppendableUpload b =
           storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), UPLOAD_CONFIG);
       ChecksummedTestContent content = ChecksummedTestContent.of(ALL_OBJECT_BYTES, 0, 5);
-      b.write(ByteBuffer.wrap(content.getBytes()));
-      BlobInfo bi = b.finalizeUpload().get(5, TimeUnit.SECONDS);
+      try (AppendableUploadWriteableByteChannel channel = b.open()) {
+        channel.write(ByteBuffer.wrap(content.getBytes()));
+      }
+      BlobInfo bi = b.getResult().get(5, TimeUnit.SECONDS);
       assertThat(bi.getSize()).isEqualTo(5);
 
       assertThat(map.get(req1)).isEqualTo(2);
@@ -1577,11 +1609,12 @@ public class ITAppendableUploadFakeTest {
       BlobId id = BlobId.of("b", "o");
 
       BlobAppendableUploadConfig uploadConfig = UPLOAD_CONFIG.withCrc32cValidationEnabled(true);
-      try (BlobAppendableUpload upload =
-          storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), uploadConfig)) {
-        upload.write(ByteBuffer.wrap(b));
-        upload.finalizeUpload().get(5, TimeUnit.SECONDS);
+      BlobAppendableUpload upload =
+          storage.blobAppendableUpload(BlobInfo.newBuilder(id).build(), uploadConfig);
+      try (AppendableUploadWriteableByteChannel channel = upload.open()) {
+        channel.write(ByteBuffer.wrap(b));
       }
+      upload.getResult().get(5, TimeUnit.SECONDS);
     }
   }
 
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadTest.java
index dc12c32c..c07e7b44 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITAppendableUploadTest.java
@@ -16,10 +16,11 @@
 package com.google.cloud.storage;
 
 import static com.google.cloud.storage.ByteSizeConstants._2MiB;
+import static com.google.cloud.storage.TestUtils.assertAll;
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.cloud.storage.Storage.BlobField;
-import com.google.cloud.storage.Storage.BlobGetOption;
+import com.google.cloud.storage.BlobAppendableUpload.AppendableUploadWriteableByteChannel;
+import com.google.cloud.storage.BlobAppendableUploadConfig.CloseAction;
 import com.google.cloud.storage.TransportCompatibility.Transport;
 import com.google.cloud.storage.it.runner.StorageITRunner;
 import com.google.cloud.storage.it.runner.annotations.Backend;
@@ -60,7 +61,9 @@ public final class ITAppendableUploadTest {
   public void testAppendableBlobUpload()
       throws IOException, ExecutionException, InterruptedException, TimeoutException {
     BlobAppendableUploadConfig uploadConfig =
-        BlobAppendableUploadConfig.of().withFlushPolicy(FlushPolicy.maxFlushSize(2000));
+        BlobAppendableUploadConfig.of()
+            .withFlushPolicy(FlushPolicy.maxFlushSize(2000))
+            .withCloseAction(CloseAction.FINALIZE_WHEN_CLOSING);
     BlobAppendableUpload upload =
         storage.blobAppendableUpload(
             BlobInfo.newBuilder(bucket, generator.randomObjectName()).build(), uploadConfig);
@@ -68,30 +71,46 @@ public final class ITAppendableUploadTest {
     byte[] bytes = DataGenerator.base64Characters().genBytes(512 * 1024);
     byte[] a1 = Arrays.copyOfRange(bytes, 0, bytes.length / 2);
     byte[] a2 = Arrays.copyOfRange(bytes, bytes.length / 2 + 1, bytes.length);
-
-    upload.write(ByteBuffer.wrap(a1));
-    upload.write(ByteBuffer.wrap(a2));
-    BlobInfo blob = upload.finalizeUpload().get(5, TimeUnit.SECONDS);
+    try (AppendableUploadWriteableByteChannel channel = upload.open()) {
+      channel.write(ByteBuffer.wrap(a1));
+      channel.write(ByteBuffer.wrap(a2));
+    }
+    BlobInfo blob = upload.getResult().get(5, TimeUnit.SECONDS);
 
     assertThat(blob.getSize()).isEqualTo(a1.length + a2.length);
+
+    BlobInfo actual = upload.getResult().get(5, TimeUnit.SECONDS);
+    BlobInfo blob1 = storage.get(actual.getBlobId());
+    assertThat(actual).isEqualTo(blob1);
   }
 
   @Test
-  public void appendableBlobUploadWithoutFinalizing() throws IOException {
+  public void appendableBlobUploadWithoutFinalizing() throws Exception {
     BlobAppendableUploadConfig uploadConfig =
         BlobAppendableUploadConfig.of().withFlushPolicy(FlushPolicy.maxFlushSize(256 * 1024));
-    BlobAppendableUpload upload =
-        storage.blobAppendableUpload(
-            BlobInfo.newBuilder(bucket, generator.randomObjectName()).build(), uploadConfig);
+    BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+    BlobAppendableUpload upload = storage.blobAppendableUpload(info, uploadConfig);
 
     byte[] bytes = DataGenerator.base64Characters().genBytes(512 * 1024);
     byte[] a1 = Arrays.copyOfRange(bytes, 0, bytes.length / 2);
     byte[] a2 = Arrays.copyOfRange(bytes, bytes.length / 2 + 1, bytes.length);
 
-    upload.write(ByteBuffer.wrap(a1));
-    upload.write(ByteBuffer.wrap(a2));
-
-    upload.close();
+    try (AppendableUploadWriteableByteChannel channel = upload.open()) {
+      channel.write(ByteBuffer.wrap(a1));
+      channel.write(ByteBuffer.wrap(a2));
+    }
+    BlobInfo actual = upload.getResult().get(5, TimeUnit.SECONDS);
+    assertAll(
+        () -> assertThat(actual).isNotNull(),
+        () -> assertThat(actual.getSize()).isEqualTo(512 * 1024 - 1),
+        () -> {
+          String crc32c = actual.getCrc32c();
+          // prod is null
+          boolean crc32cNull = crc32c == null;
+          // testbench is 0
+          boolean crc32cZero = Utils.crc32cCodec.encode(0).equalsIgnoreCase(crc32c);
+          assertThat(crc32cNull || crc32cZero).isTrue();
+        });
   }
 
   @Test
@@ -106,16 +125,18 @@ public final class ITAppendableUploadTest {
 
     byte[] bytes = "ABCDEFGHIJ".getBytes();
 
-    upload.write(ByteBuffer.wrap(bytes));
-    upload.close();
-
-    Blob blob = storage.get(bid);
+    try (AppendableUploadWriteableByteChannel channel = upload.open()) {
+      channel.write(ByteBuffer.wrap(bytes));
+    }
+    BlobInfo blob = upload.getResult().get(5, TimeUnit.SECONDS);
 
     byte[] bytes2 = "KLMNOPQRST".getBytes();
     BlobAppendableUpload takeOver =
         storage.blobAppendableUpload(BlobInfo.newBuilder(blob.getBlobId()).build(), uploadConfig);
-    takeOver.write(ByteBuffer.wrap(bytes2));
-    BlobInfo i = takeOver.finalizeUpload().get(5, TimeUnit.SECONDS);
+    try (AppendableUploadWriteableByteChannel channel = takeOver.open()) {
+      channel.write(ByteBuffer.wrap(bytes2));
+    }
+    BlobInfo i = takeOver.getResult().get(5, TimeUnit.SECONDS);
     assertThat(i.getSize()).isEqualTo(20);
   }
 
@@ -128,35 +149,19 @@ public final class ITAppendableUploadTest {
     try (TmpFile tmpFile =
         DataGenerator.base64Characters()
             .tempFile(Paths.get(System.getProperty("java.io.tmpdir")), 100 * 1024 * 1024)) {
-      try (BlobAppendableUpload appendable =
-              storage.blobAppendableUpload(BlobInfo.newBuilder(bid).build(), uploadConfig);
+
+      BlobAppendableUpload appendable =
+          storage.blobAppendableUpload(BlobInfo.newBuilder(bid).build(), uploadConfig);
+      try (AppendableUploadWriteableByteChannel channel = appendable.open();
           SeekableByteChannel r =
               Files.newByteChannel(tmpFile.getPath(), StandardOpenOption.READ)) {
-
-        ByteStreams.copy(r, appendable);
-        BlobInfo bi = appendable.finalizeUpload().get(5, TimeUnit.SECONDS);
-        assertThat(bi.getSize()).isEqualTo(100 * 1024 * 1024);
+        ByteStreams.copy(r, channel);
       }
+      BlobInfo bi = appendable.getResult().get(5, TimeUnit.SECONDS);
+      assertThat(bi.getSize()).isEqualTo(100 * 1024 * 1024);
     }
   }
 
-  @Test
-  // Pending work in testbench, manually verified internally on 2025-03-25
-  @CrossRun.Ignore(backends = {Backend.TEST_BENCH})
-  public void finalizeAfterCloseWorks() throws Exception {
-    BlobAppendableUploadConfig uploadConfig =
-        BlobAppendableUploadConfig.of().withFlushPolicy(FlushPolicy.maxFlushSize(1024));
-    BlobId bid = BlobId.of(bucket.getName(), generator.randomObjectName());
-
-    BlobAppendableUpload appendable =
-        storage.blobAppendableUpload(BlobInfo.newBuilder(bid).build(), uploadConfig);
-    appendable.write(DataGenerator.base64Characters().genByteBuffer(3587));
-
-    appendable.close();
-    BlobInfo bi = appendable.finalizeUpload().get(5, TimeUnit.SECONDS);
-    assertThat(bi.getSize()).isEqualTo(3587);
-  }
-
   @Test
   // Pending work in testbench, manually verified internally on 2025-03-25
   @CrossRun.Ignore(backends = {Backend.TEST_BENCH})
@@ -168,16 +173,22 @@ public final class ITAppendableUploadTest {
     BlobAppendableUpload upload =
         storage.blobAppendableUpload(BlobInfo.newBuilder(bid).build(), uploadConfig);
 
-    upload.write(DataGenerator.base64Characters().genByteBuffer(20));
-    upload.close();
+    try (AppendableUploadWriteableByteChannel channel = upload.open()) {
+      channel.write(DataGenerator.base64Characters().genByteBuffer(20));
+    }
 
-    Blob blob =
-        storage.get(
-            bid, BlobGetOption.fields(BlobField.BUCKET, BlobField.NAME, BlobField.GENERATION));
+    BlobInfo blob = upload.getResult().get(5, TimeUnit.SECONDS);
 
     BlobAppendableUpload takeOver =
         storage.blobAppendableUpload(BlobInfo.newBuilder(blob.getBlobId()).build(), uploadConfig);
-    BlobInfo i = takeOver.finalizeUpload().get(5, TimeUnit.SECONDS);
+    takeOver.open().finalizeAndClose();
+    BlobInfo i = takeOver.getResult().get(5, TimeUnit.SECONDS);
     assertThat(i.getSize()).isEqualTo(20);
+
+    BlobInfo actual = takeOver.getResult().get(5, TimeUnit.SECONDS);
+    assertAll(
+        () -> assertThat(actual).isNotNull(),
+        () -> assertThat(actual.getSize()).isEqualTo(20),
+        () -> assertThat(actual.getCrc32c()).isNotNull());
   }
 }
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 52f4ad38..fe6e1f50 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
@@ -26,6 +26,7 @@ import static org.junit.Assert.assertThrows;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.ApiFutures;
 import com.google.api.gax.rpc.OutOfRangeException;
+import com.google.cloud.storage.BlobAppendableUpload.AppendableUploadWriteableByteChannel;
 import com.google.cloud.storage.Crc32cValue.Crc32cLengthKnown;
 import com.google.cloud.storage.Storage.BlobWriteOption;
 import com.google.cloud.storage.TransportCompatibility.Transport;
@@ -351,11 +352,14 @@ public final class ITObjectReadSessionTest {
   private BlobInfo create(ChecksummedTestContent content)
       throws IOException, ExecutionException, InterruptedException, TimeoutException {
     BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
-    try (BlobAppendableUpload upload =
+
+    BlobAppendableUpload upload =
         storage.blobAppendableUpload(
-            info, BlobAppendableUploadConfig.of(), BlobWriteOption.doesNotExist())) {
-      upload.write(ByteBuffer.wrap(content.getBytes()));
-      return upload.finalizeUpload().get(30, TimeUnit.SECONDS);
+            info, BlobAppendableUploadConfig.of(), BlobWriteOption.doesNotExist());
+    try (AppendableUploadWriteableByteChannel channel = upload.open(); ) {
+      channel.write(ByteBuffer.wrap(content.getBytes()));
+      channel.finalizeAndClose();
     }
+    return upload.getResult().get(5, TimeUnit.SECONDS);
   }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionTest.java
new file mode 100644
index 00000000..22071173
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ObjectReadSessionTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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 static com.google.cloud.storage.TestUtils.assertAll;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.ObjectReadSessionImpl.ConcurrentIdentityMap;
+import com.google.common.base.MoreObjects;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiFunction;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public final class ObjectReadSessionTest {
+  private static final AtomicInteger vCounter = new AtomicInteger(1);
+
+  private static ListeningExecutorService exec;
+
+  @BeforeClass
+  public static void beforeClass() {
+    exec =
+        MoreExecutors.listeningDecorator(
+            Executors.newFixedThreadPool(2, new ThreadFactoryBuilder().setDaemon(true).build()));
+  }
+
+  @AfterClass
+  public static void afterClass() {
+    exec.shutdownNow();
+  }
+
+  @Test
+  public void concurrentIdentityMap_basic() throws Exception {
+    ConcurrentIdentityMap<Key, Value> map = new ConcurrentIdentityMap<>();
+
+    map.put(new Key("k1"), new Value());
+    map.put(new Key("k2"), new Value());
+    map.put(new Key("k3"), new Value());
+    map.put(new Key("k4"), new Value());
+
+    List<String> strings = map.drainEntries((k, v) -> String.format("%s -> %s", k, v));
+    assertThat(strings).hasSize(4);
+
+    String joined = String.join("\n", strings);
+    assertAll(
+        () -> assertThat(joined).contains("k1"),
+        () -> assertThat(joined).contains("k2"),
+        () -> assertThat(joined).contains("k3"),
+        () -> assertThat(joined).contains("k4"));
+  }
+
+  @Test
+  public void concurrentIdentityMap_multipleThreadsAdding() throws Exception {
+    ConcurrentIdentityMap<Key, Value> map = new ConcurrentIdentityMap<>();
+
+    CountDownLatch cdl = new CountDownLatch(1);
+    map.put(new Key("t1k1"), new Value());
+    map.put(new Key("t1k2"), new Value());
+
+    ListenableFuture<Boolean> submitted =
+        exec.submit(
+            () -> {
+              try {
+                boolean await = cdl.await(3, TimeUnit.SECONDS);
+                assertThat(await).isTrue();
+                map.put(new Key("t2k1"), new Value());
+                return true;
+              } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+              }
+            });
+
+    BiFunction<Key, Value, String> f =
+        (k, v) -> {
+          cdl.countDown();
+          return String.format("%s -> %s", k, v);
+        };
+    List<String> strings = map.drainEntries(f);
+    assertThat(strings).hasSize(2);
+    String joined = String.join("\n", strings);
+    assertAll(() -> assertThat(joined).contains("t1k1"), () -> assertThat(joined).contains("t1k2"));
+
+    submitted.get(1, TimeUnit.SECONDS);
+    List<String> drain2 = map.drainEntries(f);
+    assertThat(drain2).hasSize(1);
+  }
+
+  @Test
+  public void concurrentIdentityMap_removeAfterDrainClean() throws Exception {
+    ConcurrentIdentityMap<Key, Value> map = new ConcurrentIdentityMap<>();
+
+    CountDownLatch cdl = new CountDownLatch(1);
+    map.put(new Key("t1k1"), new Value());
+    Key t1k2 = new Key("t1k2");
+    map.put(t1k2, new Value());
+
+    ListenableFuture<Boolean> submit =
+        exec.submit(
+            () -> {
+              try {
+                boolean await = cdl.await(3, TimeUnit.SECONDS);
+                assertThat(await).isTrue();
+                map.remove(t1k2);
+                return true;
+              } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+              }
+            });
+
+    BiFunction<Key, Value, String> f =
+        (k, v) -> {
+          cdl.countDown();
+          return String.format("%s -> %s", k, v);
+        };
+    List<String> strings = map.drainEntries(f);
+    assertThat(strings).hasSize(2);
+    String joined = String.join("\n", strings);
+    assertAll(() -> assertThat(joined).contains("t1k1"), () -> assertThat(joined).contains("t1k2"));
+
+    assertThat(submit.get(1, TimeUnit.SECONDS)).isEqualTo(true);
+  }
+
+  private static final class Key {
+    private final String k;
+
+    private Key(String k) {
+      this.k = k;
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).add("k", k).toString();
+    }
+  }
+
+  private static final class Value {
+    private final String v = String.format("v/%d", vCounter.getAndIncrement());
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).add("v", v).toString();
+    }
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
index b325948b..eeae2338 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
@@ -500,8 +500,7 @@ abstract class AbstractStorageProxy implements Storage {
 
   @Override
   public BlobAppendableUpload blobAppendableUpload(
-      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options)
-      throws IOException {
+      BlobInfo blobInfo, BlobAppendableUploadConfig uploadConfig, BlobWriteOption... options) {
     return delegate.blobAppendableUpload(blobInfo, uploadConfig, options);
   }