Repository: googleapis/java-storage
Pull request #3067 (2025-04-24 20:55:05)

test: attempt to disable testbench keep-alive in an attempt to avoid re-using sockets that are in the process of breaking

BenWhitehead: Integration suite 1 passed https://btx.cloud.google.com/invocations/e6771aac-1b5b-4883-81d0-6b3ec02236ea/targets
BenWhitehead: Integration suite 2 passed https://btx.cloud.google.com/invocations/81149e34-cbd6-4f2b-b940-f1911543c30d/targets

Patch:
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 afcacd96..301792e1 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
@@ -2677,6 +2677,17 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       return new BlobListOption(UnifiedOpts.includeFoldersAsPrefixes(includeFolders));
     }
 
+    /**
+     * Returns an option which will cause blobs that end in exactly one instance of `delimiter` will
+     * have their metadata included rather than being synthetic objects.
+     *
+     * @since 2.52.0
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobListOption includeTrailingDelimiter() {
+      return new BlobListOption(UnifiedOpts.includeTrailingDelimiter());
+    }
+
     /**
      * Returns an option to define the billing user project. This option is required by buckets with
      * `requester_pays` flag enabled to assign operation costs.
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
index 1402e9cb..77cb1344 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
@@ -381,6 +381,10 @@ final class UnifiedOpts {
     return new IncludeFoldersAsPrefixes(includeFoldersAsPrefixes);
   }
 
+  static IncludeTrailingDelimiter includeTrailingDelimiter() {
+    return new IncludeTrailingDelimiter(true);
+  }
+
   @Deprecated
   static DetectContentType detectContentType() {
     return DetectContentType.INSTANCE;
@@ -706,6 +710,20 @@ final class UnifiedOpts {
     }
   }
 
+  static final class IncludeTrailingDelimiter extends RpcOptVal<Boolean> implements ObjectListOpt {
+
+    private static final long serialVersionUID = 321916692864878282L;
+
+    private IncludeTrailingDelimiter(boolean val) {
+      super(StorageRpc.Option.INCLUDE_TRAILING_DELIMITER, val);
+    }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setIncludeTrailingDelimiter(val);
+    }
+  }
+
   static final class Delimiter extends RpcOptVal<String> implements ObjectListOpt {
     private static final long serialVersionUID = -3789556789947615714L;
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
index 3111fa24..6ea50f37 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
@@ -496,7 +496,8 @@ public class HttpStorageRpc implements StorageRpc {
               .setFields(Option.FIELDS.getString(options))
               .setUserProject(Option.USER_PROJECT.getString(options))
               .setSoftDeleted(Option.SOFT_DELETED.getBoolean(options))
-              .setIncludeFoldersAsPrefixes(Option.INCLUDE_FOLDERS_AS_PREFIXES.getBoolean(options));
+              .setIncludeFoldersAsPrefixes(Option.INCLUDE_FOLDERS_AS_PREFIXES.getBoolean(options))
+              .setIncludeTrailingDelimiter(Option.INCLUDE_TRAILING_DELIMITER.getBoolean(options));
       setExtraHeaders(list, options);
       Objects objects = list.execute();
       Iterable<StorageObject> storageObjects =
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
index dc056fda..160a2ad4 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
@@ -78,6 +78,7 @@ public interface StorageRpc extends ServiceRpc {
     COPY_SOURCE_ACL("copySourceAcl"),
     GENERATION("generation"),
     INCLUDE_FOLDERS_AS_PREFIXES("includeFoldersAsPrefixes"),
+    INCLUDE_TRAILING_DELIMITER("includeTrailingDelimiter"),
     X_UPLOAD_CONTENT_LENGTH("x-upload-content-length"),
     /**
      * An {@link com.google.common.collect.ImmutableMap ImmutableMap&lt;String, String>} of values


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
index 4baad7fe..f3a79e03 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
@@ -1607,4 +1607,86 @@ public class ITObjectTest {
         () -> assertThat(gen1_2.getMetadata()).isEqualTo(meta3),
         () -> assertThat(gen1_2.getGeneration()).isEqualTo(gen1.getGeneration()));
   }
+
+  @Test
+  public void listBlob_includeTrailingDelimiter() throws Exception {
+    final byte[] A = new byte[] {(byte) 'A'};
+
+    String basePath = generator.randomObjectName();
+    // create a series of objects under a stable test specific path
+    BlobId a = BlobId.of(bucket.getName(), String.format("%s/a", basePath));
+    BlobId b = BlobId.of(bucket.getName(), String.format("%s/b", basePath));
+    BlobId c = BlobId.of(bucket.getName(), String.format("%s/c", basePath));
+    BlobId a_ = BlobId.of(bucket.getName(), String.format("%s/a/", basePath));
+    BlobId b_ = BlobId.of(bucket.getName(), String.format("%s/b/", basePath));
+    BlobId c_ = BlobId.of(bucket.getName(), String.format("%s/c/", basePath));
+    BlobId d_ = BlobId.of(bucket.getName(), String.format("%s/d/", basePath));
+    BlobId a_A1 = BlobId.of(bucket.getName(), String.format("%s/a/A1", basePath));
+    BlobId a_A2 = BlobId.of(bucket.getName(), String.format("%s/a/A2", basePath));
+    BlobId b_B1 = BlobId.of(bucket.getName(), String.format("%s/b/B1", basePath));
+    BlobId c_C2 = BlobId.of(bucket.getName(), String.format("%s/c/C2", basePath));
+
+    storage.create(BlobInfo.newBuilder(a).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(b).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(c).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(a_).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(b_).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(c_).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(d_).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(a_A1).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(a_A2).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(b_B1).build(), A, BlobTargetOption.doesNotExist());
+    storage.create(BlobInfo.newBuilder(c_C2).build(), A, BlobTargetOption.doesNotExist());
+
+    // define all our options
+    BlobListOption[] blobListOptions =
+        new BlobListOption[] {
+          BlobListOption.currentDirectory(),
+          BlobListOption.includeTrailingDelimiter(),
+          BlobListOption.fields(BlobField.NAME, BlobField.GENERATION, BlobField.SIZE),
+          BlobListOption.prefix(basePath + "/")
+        };
+    // list and collect all the object names
+    List<Blob> blobs =
+        storage.list(bucket.getName(), blobListOptions).streamAll().collect(Collectors.toList());
+
+    // figure out what the base prefix of the objects is, so we can trim it down to make assertions
+    // more terse.
+    int trimLen = String.format(Locale.US, "gs://%s/%s", bucket.getName(), basePath).length();
+    List<String> names =
+        blobs.stream()
+            .map(
+                bi -> {
+                  String uri = bi.getBlobId().toGsUtilUriWithGeneration();
+                  int genIdx = uri.indexOf("#");
+                  String substring;
+                  if (genIdx > -1) {
+                    // trim the string representation of the generation to make assertions easier.
+                    // We really only need to know that a generation is present, not it's exact
+                    // value.
+                    substring = uri.substring(trimLen, genIdx + 1);
+                  } else {
+                    substring = uri.substring(trimLen);
+                  }
+                  return "..." + substring;
+                })
+            .collect(Collectors.toList());
+
+    assertThat(names)
+        .containsExactly(
+            // items
+            ".../a#",
+            ".../b#",
+            ".../c#",
+            // items included due to includeTrailingDelimiter
+            ".../a/#",
+            ".../b/#",
+            ".../c/#",
+            ".../d/#",
+            // prefixes
+            ".../a/",
+            ".../b/",
+            ".../c/",
+            ".../d/");
+  }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
index 32557a97..3cc9360f 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
@@ -249,7 +249,14 @@ public final class TestBench implements ManagedLifecycle {
               "--publish",
               gRPCPort + ":9090",
               String.format(Locale.US, "--name=%s", containerName),
-              dockerImage);
+              dockerImage,
+              "gunicorn",
+              "--bind=0.0.0.0:9000",
+              "--worker-class=sync",
+              "--threads=10",
+              "--access-logfile=-",
+              "--keep-alive=0",
+              "testbench:run()");
       process =
           new ProcessBuilder()
               .command(command)