Repository: googleapis/java-storage
Pull request #2221 (2023-09-22 19:12:31)

chore: add StorageInternal#{delete(BlobId),internalDirectUpload}
_Pre-Work_

Add two new method to StorageInternal for access by new BlobWriteSession implementations
1. `#delete(BlobId)`
2. `#internalDirectUpload(BlobInfo, Opts<ObjectTargetOpt>, ByteBuffer)`



BenWhitehead: Apologies, I accidentally merged https://github.com/googleapis/java-storage/pull/2229 into this PR. I meant to merge it to main after this PR. You only need to worry about reviewing the first commit of this PR.

Patch:
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 36079db3..19137ee2 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
@@ -244,35 +244,9 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
   @Override
   public Blob create(
       BlobInfo blobInfo, byte[] content, int offset, int length, BlobTargetOption... options) {
-    requireNonNull(blobInfo, "blobInfo must be non null");
-    requireNonNull(content, "content must be non null");
-    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo).prepend(defaultOpts);
-    GrpcCallContext grpcCallContext =
-        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
-    WriteObjectRequest req = getWriteObjectRequest(blobInfo, opts);
-    Hasher hasher = Hasher.enabled();
-    GrpcCallContext merge = Utils.merge(grpcCallContext, Retrying.newCallContext());
-    return Retrying.run(
-        getOptions(),
-        retryAlgorithmManager.getFor(req),
-        () -> {
-          UnbufferedWritableByteChannelSession<WriteObjectResponse> session =
-              ResumableMedia.gapic()
-                  .write()
-                  .byteChannel(storageClient.writeObjectCallable().withDefaultCallContext(merge))
-                  .setByteStringStrategy(ByteStringStrategy.noCopy())
-                  .setHasher(hasher)
-                  .direct()
-                  .unbuffered()
-                  .setRequest(req)
-                  .build();
-
-          try (UnbufferedWritableByteChannel c = session.open()) {
-            c.write(ByteBuffer.wrap(content, offset, length));
-          }
-          return session.getResult();
-        },
-        this::getBlob);
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    return internalDirectUpload(blobInfo, opts, ByteBuffer.wrap(content, offset, length))
+        .asBlob(this);
   }
 
   @Override
@@ -749,6 +723,42 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
         hasher);
   }
 
+  @Override
+  public BlobInfo internalDirectUpload(
+      BlobInfo blobInfo, Opts<ObjectTargetOpt> opts, ByteBuffer buf) {
+    requireNonNull(blobInfo, "blobInfo must be non null");
+    requireNonNull(buf, "content must be non null");
+    Opts<ObjectTargetOpt> optsWithDefaults = opts.prepend(defaultOpts);
+    GrpcCallContext grpcCallContext =
+        optsWithDefaults.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    WriteObjectRequest req = getWriteObjectRequest(blobInfo, optsWithDefaults);
+    Hasher hasher = Hasher.enabled();
+    GrpcCallContext merge = Utils.merge(grpcCallContext, Retrying.newCallContext());
+    RewindableContent content = RewindableContent.of(buf);
+    return Retrying.run(
+        getOptions(),
+        retryAlgorithmManager.getFor(req),
+        () -> {
+          content.rewindTo(0);
+          UnbufferedWritableByteChannelSession<WriteObjectResponse> session =
+              ResumableMedia.gapic()
+                  .write()
+                  .byteChannel(storageClient.writeObjectCallable().withDefaultCallContext(merge))
+                  .setByteStringStrategy(ByteStringStrategy.noCopy())
+                  .setHasher(hasher)
+                  .direct()
+                  .unbuffered()
+                  .setRequest(req)
+                  .build();
+
+          try (UnbufferedWritableByteChannel c = session.open()) {
+            content.writeTo(c);
+          }
+          return session.getResult();
+        },
+        this::getBlob);
+  }
+
   @Override
   public WriteChannel writer(URL signedURL) {
     return throwHttpJsonOnly(fmtMethodName("writer", URL.class));
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfig.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfig.java
new file mode 100644
index 00000000..d2ed5b43
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfig.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright 2023 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.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.cloud.storage.MetadataField.PartRange;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.util.Base64;
+import java.util.Base64.Encoder;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.concurrent.Immutable;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * Immutable config builder for a Parallel Composite Upload
+ *
+ * @see <a
+ *     href="https://cloud.google.com/storage/docs/composing-objects">https://cloud.google.com/storage/docs/composing-objects</a>
+ * @see <a
+ *     href="https://cloud.google.com/storage/docs/parallel-composite-uploads">https://cloud.google.com/storage/docs/parallel-composite-uploads</a>
+ */
+@Immutable
+@BetaApi
+final class ParallelCompositeUploadBlobWriteSessionConfig extends BlobWriteSessionConfig {
+
+  private static final int MAX_PARTS_PER_COMPOSE = 32;
+  private final int maxPartsPerCompose;
+  private final ExecutorSupplier executorSupplier;
+  private final BufferStrategy bufferStrategy;
+  private final PartNamingStrategy partNamingStrategy;
+  private final PartCleanupStrategy partCleanupStrategy;
+
+  private ParallelCompositeUploadBlobWriteSessionConfig(
+      int maxPartsPerCompose,
+      ExecutorSupplier executorSupplier,
+      BufferStrategy bufferStrategy,
+      PartNamingStrategy partNamingStrategy,
+      PartCleanupStrategy partCleanupStrategy) {
+    this.maxPartsPerCompose = maxPartsPerCompose;
+    this.executorSupplier = executorSupplier;
+    this.bufferStrategy = bufferStrategy;
+    this.partNamingStrategy = partNamingStrategy;
+    this.partCleanupStrategy = partCleanupStrategy;
+  }
+
+  @InternalApi
+  ParallelCompositeUploadBlobWriteSessionConfig withMaxPartsPerCompose(int maxPartsPerCompose) {
+    checkArgument(
+        2 <= maxPartsPerCompose && maxPartsPerCompose <= 32,
+        "2 <= maxPartsPerCompose <= 32 (2 <= %s <= 32)",
+        maxPartsPerCompose);
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        maxPartsPerCompose,
+        executorSupplier,
+        bufferStrategy,
+        partNamingStrategy,
+        partCleanupStrategy);
+  }
+
+  @BetaApi
+  public ParallelCompositeUploadBlobWriteSessionConfig withExecutorSupplier(
+      ExecutorSupplier executorSupplier) {
+    checkNotNull(executorSupplier, "executorSupplier must be non null");
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        maxPartsPerCompose,
+        executorSupplier,
+        bufferStrategy,
+        partNamingStrategy,
+        partCleanupStrategy);
+  }
+
+  @BetaApi
+  public ParallelCompositeUploadBlobWriteSessionConfig withBufferStrategy(
+      BufferStrategy bufferStrategy) {
+    checkNotNull(bufferStrategy, "bufferStrategy must be non null");
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        maxPartsPerCompose,
+        executorSupplier,
+        bufferStrategy,
+        partNamingStrategy,
+        partCleanupStrategy);
+  }
+
+  @BetaApi
+  public ParallelCompositeUploadBlobWriteSessionConfig withPartNamingStrategy(
+      PartNamingStrategy partNamingStrategy) {
+    checkNotNull(partNamingStrategy, "partNamingStrategy must be non null");
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        maxPartsPerCompose,
+        executorSupplier,
+        bufferStrategy,
+        partNamingStrategy,
+        partCleanupStrategy);
+  }
+
+  @BetaApi
+  public ParallelCompositeUploadBlobWriteSessionConfig withPartCleanupStrategy(
+      PartCleanupStrategy partCleanupStrategy) {
+    checkNotNull(partCleanupStrategy, "partCleanupStrategy must be non null");
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        maxPartsPerCompose,
+        executorSupplier,
+        bufferStrategy,
+        partNamingStrategy,
+        partCleanupStrategy);
+  }
+
+  @BetaApi
+  static ParallelCompositeUploadBlobWriteSessionConfig of() {
+    return new ParallelCompositeUploadBlobWriteSessionConfig(
+        MAX_PARTS_PER_COMPOSE,
+        ExecutorSupplier.cachedPool(),
+        BufferStrategy.simple(ByteSizeConstants._16MiB),
+        PartNamingStrategy.noPrefix(),
+        PartCleanupStrategy.always());
+  }
+
+  @InternalApi
+  @Override
+  WriterFactory createFactory(Clock clock) throws IOException {
+    Executor executor = executorSupplier.get();
+    BufferHandlePool bufferHandlePool = bufferStrategy.get();
+    throw new IllegalStateException("Not yet implemented");
+  }
+
+  @BetaApi
+  @Immutable
+  public abstract static class BufferStrategy extends Factory<BufferHandlePool> {
+
+    private BufferStrategy() {}
+
+    @BetaApi
+    public static BufferStrategy simple(int capacity) {
+      return new SimpleBufferStrategy(capacity);
+    }
+
+    @BetaApi
+    public static BufferStrategy fixedPool(int bufferCount, int bufferCapacity) {
+      return new FixedBufferStrategy(bufferCount, bufferCapacity);
+    }
+
+    private static class SimpleBufferStrategy extends BufferStrategy {
+
+      private final int capacity;
+
+      private SimpleBufferStrategy(int capacity) {
+        this.capacity = capacity;
+      }
+
+      @Override
+      BufferHandlePool get() {
+        return BufferHandlePool.simple(capacity);
+      }
+    }
+
+    private static class FixedBufferStrategy extends BufferStrategy {
+
+      private final int bufferCount;
+      private final int bufferCapacity;
+
+      private FixedBufferStrategy(int bufferCount, int bufferCapacity) {
+        this.bufferCount = bufferCount;
+        this.bufferCapacity = bufferCapacity;
+      }
+
+      @Override
+      BufferHandlePool get() {
+        return BufferHandlePool.fixedPool(bufferCount, bufferCapacity);
+      }
+    }
+  }
+
+  @BetaApi
+  @Immutable
+  public abstract static class ExecutorSupplier extends Factory<Executor> {
+    private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger(1);
+
+    private ExecutorSupplier() {}
+
+    @BetaApi
+    public static ExecutorSupplier cachedPool() {
+      return new ExecutorSupplier() {
+        @Override
+        Executor get() {
+          ThreadFactory threadFactory = newThreadFactory();
+          return Executors.newCachedThreadPool(threadFactory);
+        }
+      };
+    }
+
+    @BetaApi
+    public static ExecutorSupplier fixedPool(int poolSize) {
+      return new ExecutorSupplier() {
+        @Override
+        Executor get() {
+          ThreadFactory threadFactory = newThreadFactory();
+          return Executors.newFixedThreadPool(poolSize, threadFactory);
+        }
+      };
+    }
+
+    @BetaApi
+    public static ExecutorSupplier useExecutor(Executor executor) {
+      return new SuppliedExecutorSupplier(executor);
+    }
+
+    @NonNull
+    private static ThreadFactory newThreadFactory() {
+      return new ThreadFactoryBuilder()
+          .setDaemon(true)
+          .setNameFormat("c.g.c:g-c-s:pcu-" + INSTANCE_COUNTER.getAndIncrement() + "-%d")
+          .build();
+    }
+
+    private static class SuppliedExecutorSupplier extends ExecutorSupplier {
+
+      private final Executor executor;
+
+      public SuppliedExecutorSupplier(Executor executor) {
+        this.executor = executor;
+      }
+
+      @Override
+      Executor get() {
+        return executor;
+      }
+    }
+  }
+
+  @BetaApi
+  @Immutable
+  public abstract static class PartNamingStrategy {
+    private static final String FIELD_SEPARATOR = ";";
+    private static final Encoder B64 = Base64.getUrlEncoder().withoutPadding();
+    private static final HashFunction OBJECT_NAME_HASH_FUNCTION = Hashing.goodFastHash(128);
+    private final SecureRandom rand;
+
+    @VisibleForTesting
+    @InternalApi
+    PartNamingStrategy(SecureRandom rand) {
+      this.rand = rand;
+    }
+
+    String fmtName(String ultimateObjectName, PartRange partRange) {
+      // generate 128 bits of random data
+      byte[] bytes = new byte[16];
+      rand.nextBytes(bytes);
+
+      // encode it to base 64, yielding 22 characters
+      String randomKey = B64.encodeToString(bytes);
+      HashCode hashCode =
+          OBJECT_NAME_HASH_FUNCTION.hashString(ultimateObjectName, StandardCharsets.UTF_8);
+      String nameDigest = B64.encodeToString(hashCode.asBytes());
+      return fmtFields(randomKey, nameDigest, partRange.encode());
+    }
+
+    protected abstract String fmtFields(String randomKey, String nameDigest, String partRange);
+
+    @BetaApi
+    public static PartNamingStrategy noPrefix() {
+      SecureRandom rand = new SecureRandom();
+      return new NoPrefix(rand);
+    }
+
+    @BetaApi
+    public static PartNamingStrategy prefix(String prefixPattern) {
+      checkNotNull(prefixPattern, "prefixPattern must be non null");
+      SecureRandom rand = new SecureRandom();
+      return new WithPrefix(rand, prefixPattern);
+    }
+
+    static final class WithPrefix extends PartNamingStrategy {
+
+      private final String prefix;
+
+      private WithPrefix(SecureRandom rand, String prefix) {
+        super(rand);
+        this.prefix = prefix;
+      }
+
+      @Override
+      protected String fmtFields(String randomKey, String nameDigest, String partRange) {
+        return prefix
+            + "/"
+            + randomKey
+            + FIELD_SEPARATOR
+            + nameDigest
+            + FIELD_SEPARATOR
+            + partRange
+            + ".part";
+      }
+    }
+
+    static final class NoPrefix extends PartNamingStrategy {
+      public NoPrefix(SecureRandom rand) {
+        super(rand);
+      }
+
+      @Override
+      protected String fmtFields(String randomKey, String nameDigest, String partRange) {
+        return randomKey
+            + FIELD_SEPARATOR
+            // todo: do we want to
+            // include a hint where the object came from, similar to gcloud
+            // https://cloud.google.com/storage/docs/parallel-composite-uploads#gcloud-pcu
+            // + "com.google.cloud:google-cloud-storage"
+            // + FIELD_SEPARATOR
+            + nameDigest
+            + FIELD_SEPARATOR
+            + partRange
+            + ".part";
+      }
+    }
+  }
+
+  @BetaApi
+  @Immutable
+  public static class PartCleanupStrategy {
+    private final boolean deleteParts;
+    private final boolean deleteOnError;
+
+    private PartCleanupStrategy(boolean deleteParts, boolean deleteOnError) {
+      this.deleteParts = deleteParts;
+      this.deleteOnError = deleteOnError;
+    }
+
+    boolean isDeleteParts() {
+      return deleteParts;
+    }
+
+    boolean isDeleteOnError() {
+      return deleteOnError;
+    }
+
+    /**
+     * If an unrecoverable error is encountered, define whether to attempt to delete any object
+     * parts already uploaded.
+     */
+    @BetaApi
+    public PartCleanupStrategy withDeleteOnError(boolean deleteOnError) {
+      return new PartCleanupStrategy(deleteParts, deleteOnError);
+    }
+
+    @BetaApi
+    public static PartCleanupStrategy always() {
+      return new PartCleanupStrategy(true, true);
+    }
+
+    @BetaApi
+    public static PartCleanupStrategy never() {
+      return new PartCleanupStrategy(false, false);
+    }
+  }
+
+  private abstract static class Factory<T> {
+    abstract T get();
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
index 166ccbc9..437ef49c 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
@@ -198,8 +198,8 @@ abstract class RewindableContent extends AbstractHttpContent {
     @Override
     void rewindTo(long offset) {
       Preconditions.checkArgument(
-          offset < totalLength,
-          "provided offset must be less than totalLength (%s < %s)",
+          offset <= totalLength,
+          "provided offset must be less than or equal to totalLength (%s <= %s)",
           offset,
           totalLength);
       if (dirty || offset != this.offset) {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
index 3a52dd16..f8b4f94a 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
@@ -136,10 +136,15 @@ public final class StorageException extends BaseHttpServiceException {
     }
     // If there is a gRPC exception in our cause change pull it's error message up to be our
     // message otherwise, create a generic error message with the status code.
-    String message =
-        Utils.firstNonNull(
-            () -> getStatusExceptionMessage(apiEx),
-            () -> String.format("Error: %s", statusCode.getCode().name()));
+    String statusCodeName = statusCode.getCode().name();
+    String statusExceptionMessage = getStatusExceptionMessage(apiEx);
+
+    String message;
+    if (statusExceptionMessage != null) {
+      message = statusCodeName + ": " + statusExceptionMessage;
+    } else {
+      message = "Error: " + statusCodeName;
+    }
 
     // It'd be better to use ExceptionData and BaseServiceException#<init>(ExceptionData) but,
     // BaseHttpServiceException does not pass that through so we're stuck using this for now.
@@ -191,12 +196,16 @@ public final class StorageException extends BaseHttpServiceException {
   }
 
   @Nullable
-  private static String getStatusExceptionMessage(ApiException apiEx) {
-    Throwable cause = apiEx.getCause();
-    if (cause instanceof StatusRuntimeException || cause instanceof StatusException) {
-      return cause.getMessage();
+  private static String getStatusExceptionMessage(Exception apiEx) {
+    if (apiEx.getMessage() != null) {
+      return apiEx.getMessage();
+    } else {
+      Throwable cause = apiEx.getCause();
+      if (cause instanceof StatusRuntimeException || cause instanceof StatusException) {
+        return cause.getMessage();
+      }
+      return null;
     }
-    return null;
   }
 
   @Nullable
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageInternal.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageInternal.java
index 53fdc4e9..9674eb28 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageInternal.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageInternal.java
@@ -19,12 +19,25 @@ package com.google.cloud.storage;
 import com.google.cloud.storage.UnifiedOpts.ObjectTargetOpt;
 import com.google.cloud.storage.UnifiedOpts.Opts;
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.nio.file.Path;
 
 interface StorageInternal {
 
-  BlobInfo internalCreateFrom(Path path, BlobInfo info, Opts<ObjectTargetOpt> opts)
-      throws IOException;
+  default BlobInfo internalCreateFrom(Path path, BlobInfo info, Opts<ObjectTargetOpt> opts)
+      throws IOException {
+    throw new UnsupportedOperationException("not implemented");
+  }
 
-  StorageWriteChannel internalWriter(BlobInfo info, Opts<ObjectTargetOpt> opts);
+  default StorageWriteChannel internalWriter(BlobInfo info, Opts<ObjectTargetOpt> opts) {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  default BlobInfo internalDirectUpload(BlobInfo info, Opts<ObjectTargetOpt> opts, ByteBuffer buf) {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  default boolean delete(BlobId id) {
+    throw new UnsupportedOperationException("not implemented");
+  }
 }


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/BufferToDiskThenUploadTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/BufferToDiskThenUploadTest.java
index 522be99b..60272fec 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/BufferToDiskThenUploadTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/BufferToDiskThenUploadTest.java
@@ -66,11 +66,6 @@ public final class BufferToDiskThenUploadTest {
                 actualBytes.compareAndSet(null, actual);
                 return info;
               }
-
-              @Override
-              public StorageWriteChannel internalWriter(BlobInfo info, Opts<ObjectTargetOpt> opts) {
-                return null;
-              }
             },
             blobInfo,
             Opts.empty(),
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfigTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfigTest.java
new file mode 100644
index 00000000..55625940
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ParallelCompositeUploadBlobWriteSessionConfigTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 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 static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.cloud.storage.MetadataField.PartRange;
+import com.google.cloud.storage.ParallelCompositeUploadBlobWriteSessionConfig.PartNamingStrategy;
+import com.google.common.truth.StringSubject;
+import org.junit.Test;
+
+public final class ParallelCompositeUploadBlobWriteSessionConfigTest {
+
+  @Test
+  public void partNameStrategy_noPrefix() throws Exception {
+    PartNamingStrategy strategy = PartNamingStrategy.noPrefix();
+
+    String fmt = strategy.fmtName("object23", PartRange.of(1, 32));
+    assertAll(
+        // random digest to spread over keyspace
+        () -> assertField(fmt, 0).hasLength(22),
+        // name digest
+        () -> assertField(fmt, 1).hasLength(22),
+        () -> assertField(fmt, 2).isEqualTo("0001-0032.part"));
+  }
+
+  @Test
+  public void partNameStrategy_prefix() throws Exception {
+    PartNamingStrategy strategy = PartNamingStrategy.prefix("asdf");
+
+    String fmt = strategy.fmtName("301object23", PartRange.of(1, 96));
+    assertAll(
+        // random digest with prefix to spread over keyspace
+        // digest is 22, prefix is 4, slash is 1
+        () -> assertField(fmt, 0).hasLength(22 + 5),
+        // name digest
+        () -> assertField(fmt, 1).hasLength(22),
+        () -> assertField(fmt, 2).isEqualTo("0001-0096.part"),
+        () -> assertThat(fmt).startsWith("asdf/"));
+  }
+
+  @Test
+  public void partNameStrategy_prefix_stillWorksWithFmtPattern() throws Exception {
+    PartNamingStrategy strategy = PartNamingStrategy.prefix("[%s]");
+
+    String fmt = strategy.fmtName("301object23", PartRange.of(1, 96));
+    assertAll(
+        // random digest with prefix to spread over keyspace
+        // digest is 22, prefix is 4, slash is 1
+        () -> assertField(fmt, 0).hasLength(22 + 5),
+        // name digest
+        () -> assertField(fmt, 1).hasLength(22),
+        () -> assertField(fmt, 2).isEqualTo("0001-0096.part"),
+        () -> assertThat(fmt).startsWith("[%s]/"));
+  }
+
+  private static StringSubject assertField(String fmt, int idx) {
+    String[] split = fmt.split(";");
+    String s = split[idx];
+    return assertWithMessage(String.format("Formatted name '%s', field[%d] = %s", fmt, idx, s))
+        .that(s);
+  }
+}