diff --git a/ambry-api/src/main/java/com/github/ambry/config/MySqlNamedBlobDbConfig.java b/ambry-api/src/main/java/com/github/ambry/config/MySqlNamedBlobDbConfig.java index 6fe0570152..e10fc46b02 100644 --- a/ambry-api/src/main/java/com/github/ambry/config/MySqlNamedBlobDbConfig.java +++ b/ambry-api/src/main/java/com/github/ambry/config/MySqlNamedBlobDbConfig.java @@ -32,6 +32,7 @@ public class MySqlNamedBlobDbConfig { public static final String LIST_MAX_RESULTS = PREFIX + "list.max.results"; public static final String QUERY_STALE_DATA_MAX_RESULTS = PREFIX + "query.stale.data.max.results"; public static final String STALE_DATA_RETENTION_DAYS = PREFIX + "stale.data.retention.days"; + public static final String LIST_QUERY_TIMEOUT_SECONDS = PREFIX + "list.query.timeout.seconds"; public static final String TRANSACTION_ISOLATION_LEVEL = PREFIX + "transaction.isolation.level"; public static final String LIST_NAMED_BLOBS_SQL_OPTION = "list.named.blobs.sql.option"; public static final String ENABLE_HARD_DELETE = PREFIX + "enable.hard.delete"; @@ -117,6 +118,20 @@ public class MySqlNamedBlobDbConfig { @Default("5") public final int staleDataRetentionDays; + /** + * Per-statement timeout (in seconds) applied to LIST queries via {@link java.sql.Statement#setQueryTimeout(int)}. + * Guards against a LIST scanning an unexpectedly large container: when the budget is exceeded the JDBC driver + * issues a clean cancel and the server throws a {@link java.sql.SQLException} (e.g. MySQLTimeoutException), + * which surfaces as an ordinary error rather than tearing the socket ("Communications link failure" -> HTTP 500). + * + *
Default 0 disables the timeout, making this a no-op for existing deployments. Operators on fabrics + * where a network/socket timeout is shorter than the server statement kill should set this just below that + * socket timeout so a slow LIST fails fast and cleanly instead of poisoning the connection. + */ + @Config(LIST_QUERY_TIMEOUT_SECONDS) + @Default("0") + public final int listQueryTimeoutSeconds; + /** * Transaction isolation level to be set on DB Connection. When nothing is set, default MySQL DB transaction level * (REPEATABLE_READ) will take effect. @@ -164,6 +179,8 @@ public MySqlNamedBlobDbConfig(VerifiableProperties verifiableProperties) { verifiableProperties.getIntInRange(QUERY_STALE_DATA_MAX_RESULTS, 1000, 1, Integer.MAX_VALUE); this.staleDataRetentionDays = verifiableProperties.getIntInRange(STALE_DATA_RETENTION_DAYS, 5, 1, Integer.MAX_VALUE); + this.listQueryTimeoutSeconds = + verifiableProperties.getIntInRange(LIST_QUERY_TIMEOUT_SECONDS, 0, 0, Integer.MAX_VALUE); this.transactionIsolationLevel = verifiableProperties.getEnum(TRANSACTION_ISOLATION_LEVEL, TransactionIsolationLevel.class, TransactionIsolationLevel.TRANSACTION_NONE); diff --git a/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3IntegrationTest.java b/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3IntegrationTest.java index 678caadbf1..56cce9a1d7 100644 --- a/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3IntegrationTest.java +++ b/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3IntegrationTest.java @@ -327,15 +327,90 @@ private void doListBlob(String account, String container, String key) throws Exc assertEquals("Mismatch in encoding type", "url", listBucketResult.getEncodingType()); } + /** + * Reproduces the request shape that the AWS S3 SDK emits when the caller sets an unset/empty + * prefix on ListObjectsRequest: the query string contains {@code prefix=} (parameter present, + * value empty) rather than omitting the parameter entirely. This is the exact shape that + * caused the original empty-prefix regression on option 4 (LIST_WITH_PREFIX_SQL evaluated + * {@code blob_name LIKE '%'} over a full container scan, timing out at MAX_EXECUTION_TIME); + * fixed in linkedin/ambry#3265 by collapsing empty {@code prefix} to {@code null} in + * {@link NamedBlobPath#parseS3}. + * + * This test asserts the request path returns 200 OK end-to-end through the S3 handler stack + * (HTTP → Netty → {@code S3ListHandler} → {@code NamedBlobPath.parseS3} → {@code + * NamedBlobListHandler} → {@code NamedBlobDb#list}). The named-blob DB in this integration + * test is {@code InMemNamedBlobDbFactory}, so this test specifically catches regressions in + * the S3-handler routing layer (empty-prefix collapse, parseS3 logic) — not SQL-side + * regressions, which are covered by + * {@code MySqlNamedBlobDbListOperationIntegrationTest#testListNamedBlobsWithNullPrefix} + * against a real MySQL backend. + */ + @Test + public void s3ListEmptyPrefixTest() throws Exception { + Container container = ACCOUNT.getAllContainers().iterator().next(); + String account = ACCOUNT.getName(); + String containerName = container.getName(); + + // Seed a couple of blobs so the LIST has something to return — the test focuses on the + // request shape and routing, not on the content of the response. + String[] keys = new String[]{"empty_prefix_seed_a", "empty_prefix_seed_b"}; + int contentSize = 64; + for (String key : keys) { + byte[] content = TestUtils.getRandomBytes(contentSize); + doPutBlob(account, containerName, key, contentSize, content); + } + + // V1 LIST: GET /s3/{account}/{container}?prefix= (explicit empty value) + String uriV1 = String.format("/s3/%s/%s?prefix=", account, containerName); + HttpHeaders headers = new DefaultHttpHeaders(); + FullHttpRequest reqV1 = buildRequest(HttpMethod.GET, uriV1, headers, null); + NettyClient.ResponseParts partsV1 = nettyClient.sendRequest(reqV1, null, null).get(); + HttpResponse respV1 = getHttpResponse(partsV1); + assertEquals("LIST v1 with explicit empty prefix should return 200 OK end-to-end through " + + "the S3 handler stack; regression in parseS3 empty-prefix collapse would surface here", + HttpResponseStatus.OK, respV1.status()); + + // V2 LIST: GET /s3/{account}/{container}?prefix=&list-type=2 + String uriV2 = String.format("/s3/%s/%s?prefix=&list-type=2", account, containerName); + FullHttpRequest reqV2 = buildRequest(HttpMethod.GET, uriV2, new DefaultHttpHeaders(), null); + NettyClient.ResponseParts partsV2 = nettyClient.sendRequest(reqV2, null, null).get(); + HttpResponse respV2 = getHttpResponse(partsV2); + assertEquals("LIST v2 with explicit empty prefix should return 200 OK end-to-end", + HttpResponseStatus.OK, respV2.status()); + + // Cleanup + for (String key : keys) { + String deleteUri = String.format("/s3/%s/%s/%s", account, containerName, key); + FullHttpRequest delReq = buildRequest(HttpMethod.DELETE, deleteUri, new DefaultHttpHeaders(), null); + nettyClient.sendRequest(delReq, null, null).get(); + } + } + /** * Builds properties required to start a {@link RestServer} as an Ambry frontend server. * @param trustStoreFile the trust store file to add certificates to for SSL testing. * @param account {@link Account} for which quota needs to be specified. * @return a {@link VerifiableProperties} with the parameters for an Ambry frontend server. */ - private static VerifiableProperties buildFrontendVPropsForQuota(File trustStoreFile, Account account) + static VerifiableProperties buildFrontendVPropsForQuota(File trustStoreFile, Account account) throws IOException, GeneralSecurityException { - Properties properties = buildFrontendVProps(trustStoreFile); + return buildFrontendVPropsForQuota(trustStoreFile, account, "com.github.ambry.commons.InMemNamedBlobDbFactory", + null); + } + + /** + * Builds quota-enabled frontend properties, letting the caller pick the named-blob DB factory (e.g. the + * MySQL-backed factory) and supply extra properties (e.g. the dbInfo and LIST SQL option). Reused by + * sibling integration tests that exercise the S3 stack against a real backend. + * @param trustStoreFile the trust store file to add certificates to for SSL testing. + * @param account {@link Account} for which quota needs to be specified. + * @param namedBlobDbFactory the fully-qualified {@link com.github.ambry.named.NamedBlobDbFactory} class name. + * @param extraProps additional properties to layer on top (may be null). + * @return a {@link VerifiableProperties} with the parameters for an Ambry frontend server. + */ + static VerifiableProperties buildFrontendVPropsForQuota(File trustStoreFile, Account account, + String namedBlobDbFactory, Properties extraProps) throws IOException, GeneralSecurityException { + Properties properties = buildFrontendVProps(trustStoreFile, namedBlobDbFactory, extraProps); JSONObject cuResourceQuotaJson = new JSONObject(); JSONObject quotaJson = new JSONObject(); quotaJson.put("rcu", 10737418240L); @@ -354,7 +429,18 @@ private static VerifiableProperties buildFrontendVPropsForQuota(File trustStoreF * @param trustStoreFile the trust store file to add certificates to for SSL testing. * @return a {@link Properties} with the parameters for an Ambry frontend server. */ - private static Properties buildFrontendVProps(File trustStoreFile) + static Properties buildFrontendVProps(File trustStoreFile) throws IOException, GeneralSecurityException { + return buildFrontendVProps(trustStoreFile, "com.github.ambry.commons.InMemNamedBlobDbFactory", null); + } + + /** + * Builds frontend properties with a caller-selected named-blob DB factory and optional extra properties. + * @param trustStoreFile the trust store file to add certificates to for SSL testing. + * @param namedBlobDbFactory the fully-qualified {@link com.github.ambry.named.NamedBlobDbFactory} class name. + * @param extraProps additional properties to layer on top (may be null). + * @return a {@link Properties} with the parameters for an Ambry frontend server. + */ + static Properties buildFrontendVProps(File trustStoreFile, String namedBlobDbFactory, Properties extraProps) throws IOException, GeneralSecurityException { Properties properties = new Properties(); properties.put("rest.server.rest.request.service.factory", @@ -377,8 +463,11 @@ private static Properties buildFrontendVProps(File trustStoreFile) properties.setProperty("clustermap.datacenter.name", DATA_CENTER_NAME); properties.setProperty("clustermap.host.name", HOST_NAME); properties.setProperty(FrontendConfig.ENABLE_UNDELETE, Boolean.toString(true)); - properties.setProperty(FrontendConfig.NAMED_BLOB_DB_FACTORY, "com.github.ambry.commons.InMemNamedBlobDbFactory"); + properties.setProperty(FrontendConfig.NAMED_BLOB_DB_FACTORY, namedBlobDbFactory); properties.setProperty(MySqlNamedBlobDbConfig.LIST_MAX_RESULTS, String.valueOf(NAMED_BLOB_LIST_RESULT_MAX)); + if (extraProps != null) { + properties.putAll(extraProps); + } return properties; } } diff --git a/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3MySqlNamedBlobListIntegrationTest.java b/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3MySqlNamedBlobListIntegrationTest.java new file mode 100644 index 0000000000..f2c94abd4f --- /dev/null +++ b/ambry-frontend/src/integration-test/java/com/github/ambry/frontend/S3MySqlNamedBlobListIntegrationTest.java @@ -0,0 +1,212 @@ +/** + * Copyright 2026 LinkedIn Corp. All rights reserved. + * + * 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. + */ +package com.github.ambry.frontend; + +import com.github.ambry.account.Account; +import com.github.ambry.account.Container; +import com.github.ambry.account.InMemAccountService; +import com.github.ambry.account.InMemAccountServiceFactory; +import com.github.ambry.clustermap.MockClusterMap; +import com.github.ambry.commons.LoggingNotificationSystem; +import com.github.ambry.commons.SSLFactory; +import com.github.ambry.commons.TestSSLUtils; +import com.github.ambry.config.FrontendConfig; +import com.github.ambry.config.MySqlNamedBlobDbConfig; +import com.github.ambry.config.SSLConfig; +import com.github.ambry.config.VerifiableProperties; +import com.github.ambry.quota.QuotaResourceType; +import com.github.ambry.rest.NettyClient; +import com.github.ambry.rest.RestServer; +import com.github.ambry.utils.TestUtils; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import java.io.File; +import java.nio.ByteBuffer; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collections; +import java.util.Properties; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import static com.github.ambry.rest.RestUtils.Headers.*; +import static com.github.ambry.rest.RestUtils.*; +import static org.junit.Assert.*; + + +/** + * Integration test that exercises the S3 empty-prefix LIST path end-to-end against a REAL MySQL backend under + * {@code listNamedBlobsSqlOption=4}: HTTP -> Netty -> S3ListHandler -> NamedBlobPath.parseS3 (empty prefix collapses + * to null) -> NamedBlobListHandler -> {@link com.github.ambry.named.MySqlNamedBlobDb#list} -> LIST_ALL (option 4). + * + *
This is the stitched coverage that {@code S3IntegrationTest#s3ListEmptyPrefixTest} could not provide (it uses + * {@code InMemNamedBlobDbFactory}, so it never runs the SQL), and that the SQL-only int test + * {@code MySqlNamedBlobDbListOperationIntegrationTest} could not provide (it never goes through the S3 handler). It is + * the only layer that runs the exact path that returned HTTP 500 on large containers when LIST_ALL option 4 used a + * window-function plan that materialized the whole container. + * + *
The CI {@code int-test} job provisions {@code localhost/AmbryNamedBlobs} (NamedBlobsSchema) and the {@code travis}
+ * user, so this test runs there. When no such MySQL is reachable (e.g. local dev), it is skipped via {@link Assume}.
+ */
+public class S3MySqlNamedBlobListIntegrationTest extends FrontendIntegrationTestBase {
+ // dbInfo "datacenter" MUST match clustermap.datacenter.name that S3IntegrationTest.buildFrontendVProps sets.
+ private static final String DATA_CENTER_NAME = "localDc";
+ private static final String NAMED_BLOB_DB_URL = "jdbc:mysql://localhost/AmbryNamedBlobs?serverTimezone=UTC";
+ private static final String NAMED_BLOB_DB_USER = "travis";
+ private static final String NAMED_BLOB_DB_PASSWORD = "";
+ private static final String DB_INFO =
+ "[{\"url\":\"" + NAMED_BLOB_DB_URL + "\",\"datacenter\":\"" + DATA_CENTER_NAME
+ + "\",\"isWriteable\":\"true\",\"username\":\"" + NAMED_BLOB_DB_USER + "\",\"password\":\""
+ + NAMED_BLOB_DB_PASSWORD + "\",\"sslMode\":\"NONE\"}]";
+
+ private static final MockClusterMap CLUSTER_MAP;
+ private static final VerifiableProperties FRONTEND_VERIFIABLE_PROPS;
+ private static final VerifiableProperties SSL_CLIENT_VERIFIABLE_PROPS;
+ private static final FrontendConfig FRONTEND_CONFIG;
+ // InMemAccountServiceFactory returns a (returnOnlyUnknown, notifyConsumers)-keyed singleton, so this is the SAME
+ // instance the frontend's account service resolves against -> the account created here is visible to MySqlNamedBlobDb.
+ private static final InMemAccountService ACCOUNT_SERVICE =
+ new InMemAccountServiceFactory(false, true).getAccountService();
+ private static final Account ACCOUNT;
+ private static RestServer ambryRestServer = null;
+ private static NettyClient plaintextNettyClient = null;
+ private static NettyClient sslNettyClient = null;
+ private static boolean mysqlAvailable = false;
+
+ static {
+ try {
+ CLUSTER_MAP = new MockClusterMap();
+ File trustStoreFile = File.createTempFile("truststore", ".jks");
+ trustStoreFile.deleteOnExit();
+ SSL_CLIENT_VERIFIABLE_PROPS = TestSSLUtils.createSslProps("", SSLFactory.Mode.CLIENT, trustStoreFile, "client");
+ ACCOUNT_SERVICE.clear();
+ ACCOUNT_SERVICE.updateAccounts(Collections.singletonList(InMemAccountService.UNKNOWN_ACCOUNT));
+ ACCOUNT = ACCOUNT_SERVICE.createAndAddRandomAccount(QuotaResourceType.ACCOUNT);
+ Properties mysqlProps = new Properties();
+ mysqlProps.setProperty(MySqlNamedBlobDbConfig.DB_INFO, DB_INFO);
+ // Exercise the option-4 LIST_ALL path end-to-end (the empty-prefix -> null-prefix LIST that 500'd on large containers).
+ mysqlProps.setProperty(MySqlNamedBlobDbConfig.LIST_NAMED_BLOBS_SQL_OPTION,
+ Integer.toString(MySqlNamedBlobDbConfig.MAX_LIST_NAMED_BLOBS_SQL_OPTION));
+ FRONTEND_VERIFIABLE_PROPS = S3IntegrationTest.buildFrontendVPropsForQuota(trustStoreFile, ACCOUNT,
+ "com.github.ambry.named.MySqlNamedBlobDbFactory", mysqlProps);
+ FRONTEND_CONFIG = new FrontendConfig(FRONTEND_VERIFIABLE_PROPS);
+ } catch (Throwable t) {
+ throw new IllegalStateException(t);
+ }
+ }
+
+ public S3MySqlNamedBlobListIntegrationTest() {
+ super(FRONTEND_CONFIG, sslNettyClient);
+ }
+
+ @BeforeClass
+ public static void setup() throws Exception {
+ // Skip cleanly when no int-test MySQL is reachable (e.g. local dev without it). CI's int-test job provisions
+ // localhost/AmbryNamedBlobs with the NamedBlobsSchema, so the test runs there.
+ try (Connection ignored = DriverManager.getConnection(NAMED_BLOB_DB_URL, NAMED_BLOB_DB_USER,
+ NAMED_BLOB_DB_PASSWORD)) {
+ mysqlAvailable = true;
+ } catch (SQLException e) {
+ Assume.assumeNoException("MySQL (localhost/AmbryNamedBlobs) not reachable; skipping MySQL-backed S3 LIST test",
+ e);
+ }
+ ambryRestServer = new RestServer(FRONTEND_VERIFIABLE_PROPS, CLUSTER_MAP, new LoggingNotificationSystem(),
+ SSLFactory.getNewInstance(new SSLConfig(FRONTEND_VERIFIABLE_PROPS)));
+ ambryRestServer.start();
+ plaintextNettyClient = new NettyClient("localhost", PLAINTEXT_SERVER_PORT, null);
+ sslNettyClient = new NettyClient("localhost", SSL_SERVER_PORT,
+ SSLFactory.getNewInstance(new SSLConfig(SSL_CLIENT_VERIFIABLE_PROPS)));
+ }
+
+ @AfterClass
+ public static void teardown() {
+ if (plaintextNettyClient != null) {
+ plaintextNettyClient.close();
+ }
+ if (sslNettyClient != null) {
+ sslNettyClient.close();
+ }
+ if (ambryRestServer != null) {
+ ambryRestServer.shutdown();
+ }
+ }
+
+ /**
+ * Clears any rows left from a prior run so the empty-prefix LIST assertions are deterministic across reruns.
+ */
+ @Before
+ public void before() throws Exception {
+ Assume.assumeTrue(mysqlAvailable);
+ this.nettyClient = sslNettyClient;
+ Container container = ACCOUNT.getAllContainers().iterator().next();
+ try (Connection connection = DriverManager.getConnection(NAMED_BLOB_DB_URL, NAMED_BLOB_DB_USER,
+ NAMED_BLOB_DB_PASSWORD); Statement statement = connection.createStatement()) {
+ statement.executeUpdate(String.format("DELETE FROM named_blobs_v2 WHERE account_id = %d AND container_id = %d",
+ ACCOUNT.getId(), container.getId()));
+ }
+ }
+
+ /**
+ * PUT a few named blobs through the S3 stack, then issue an empty-prefix LIST (both v1 and v2 shapes) and assert
+ * 200 OK end-to-end. With {@code listNamedBlobsSqlOption=4}, an empty prefix collapses to a null prefix and runs the
+ * correlated-subquery LIST_ALL form against real MySQL. A regression in the S3-handler routing, the empty-prefix
+ * collapse, or the option-4 LIST_ALL SQL/param binding surfaces here as a non-200 (or a 500 on the window plan).
+ */
+ @Test
+ public void s3EmptyPrefixListAgainstMySqlOption4Test() throws Exception {
+ String account = ACCOUNT.getName();
+ Container container = ACCOUNT.getAllContainers().iterator().next();
+ String containerName = container.getName();
+
+ String[] keys = new String[]{"empty_prefix_mysql_a", "empty_prefix_mysql_b", "empty_prefix_mysql_c"};
+ int contentSize = 64;
+ for (String key : keys) {
+ doPutBlob(account, containerName, key, contentSize, TestUtils.getRandomBytes(contentSize));
+ }
+
+ // V1 LIST: GET /s3/{account}/{container}?prefix= (explicit empty value)
+ String uriV1 = String.format("/s3/%s/%s?prefix=", account, containerName);
+ FullHttpRequest reqV1 = buildRequest(HttpMethod.GET, uriV1, new DefaultHttpHeaders(), null);
+ HttpResponse respV1 = getHttpResponse(nettyClient.sendRequest(reqV1, null, null).get());
+ assertEquals("Empty-prefix LIST (v1) through the S3 stack against MySQL option-4 LIST_ALL must return 200 OK",
+ HttpResponseStatus.OK, respV1.status());
+
+ // V2 LIST: GET /s3/{account}/{container}?prefix=&list-type=2
+ String uriV2 = String.format("/s3/%s/%s?prefix=&list-type=2", account, containerName);
+ FullHttpRequest reqV2 = buildRequest(HttpMethod.GET, uriV2, new DefaultHttpHeaders(), null);
+ HttpResponse respV2 = getHttpResponse(nettyClient.sendRequest(reqV2, null, null).get());
+ assertEquals("Empty-prefix LIST (v2) through the S3 stack against MySQL option-4 LIST_ALL must return 200 OK",
+ HttpResponseStatus.OK, respV2.status());
+ }
+
+ private void doPutBlob(String account, String container, String key, int contentSize, byte[] content)
+ throws Exception {
+ String uri = String.format("/s3/%s/%s/%s", account, container, key);
+ HttpHeaders headers = new DefaultHttpHeaders();
+ headers.add(CONTENT_TYPE, OCTET_STREAM_CONTENT_TYPE);
+ headers.add(CONTENT_LENGTH, contentSize);
+ FullHttpRequest httpRequest = buildRequest(HttpMethod.PUT, uri, headers, ByteBuffer.wrap(content));
+ HttpResponse response = getHttpResponse(nettyClient.sendRequest(httpRequest, null, null).get());
+ assertEquals("Unexpected status putting seed blob " + key, HttpResponseStatus.OK, response.status());
+ }
+}
diff --git a/ambry-named-mysql/src/integration-test/java/com/github/ambry/named/MySqlNamedBlobDbListOperationIntegrationTest.java b/ambry-named-mysql/src/integration-test/java/com/github/ambry/named/MySqlNamedBlobDbListOperationIntegrationTest.java
index a5970c593b..de6469ee87 100644
--- a/ambry-named-mysql/src/integration-test/java/com/github/ambry/named/MySqlNamedBlobDbListOperationIntegrationTest.java
+++ b/ambry-named-mysql/src/integration-test/java/com/github/ambry/named/MySqlNamedBlobDbListOperationIntegrationTest.java
@@ -30,6 +30,7 @@
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
+import org.junit.Assume;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
@@ -224,6 +225,187 @@ public void testListHidesBlobWhenLatestVersionIsExpired() throws Exception {
+ page.getEntries(), 0, page.getEntries().size());
}
+ /**
+ * Test case for list named blobs with a null prefix — the LIST_ALL_QUERY code path.
+ *
+ * Closes a coverage gap surfaced after #3265: prior to this test, every list() invocation in
+ * this integration suite passed a non-null prefix, so the no-prefix LIST code path
+ * (LIST_ALL_QUERY for options 2/3, LIST_ALL_SQL window-function variant for option 4) was
+ * never exercised by the int-test matrix. The empty-prefix LIST regression that motivated #3265
+ * specifically crossed this path (S3 SDK sends `prefix=` empty → parseS3 collapses to null → list() with
+ * null prefix → LIST_ALL_QUERY under option 4), and option 4's window-function variant only
+ * shipped with unit-test coverage. This test runs against options 2/3/4 × hard-delete on/off
+ * via the existing parameterized matrix.
+ *
+ * Option-agnostic: PUT N distinct blobs in one container, LIST with null prefix, expect all N
+ * back in blob_name order. No multi-version / deleted-latest scenario here — see the
+ * follow-up testListAllNullPrefixHidesDeletedLatestUnderOption4 for that, which has
+ * different semantics across options 2/3 vs option 4 and so is option-4-only.
+ */
+ @Test
+ public void testListNamedBlobsWithNullPrefix() throws Exception {
+ Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ time.setCurrentMilliseconds(calendar.getTimeInMillis());
+
+ Account account = accountService.getAllAccounts().iterator().next();
+ Container container = account.getAllContainers().iterator().next();
+
+ // Seed N blobs with stable lexicographic names so we can assert ordering deterministically.
+ final int N = 5;
+ final String[] blobNames = new String[N];
+ for (int i = 0; i < N; i++) {
+ blobNames[i] = String.format("testListAllNullPrefix-%02d-%s", i, TestUtils.getRandomKey(8));
+ }
+ Arrays.sort(blobNames);
+ for (String name : blobNames) {
+ NamedBlobRecord record = new NamedBlobRecord(account.getName(), container.getName(), name,
+ getBlobId(account, container),
+ calendar.getTimeInMillis() + TimeUnit.HOURS.toMillis(1));
+ namedBlobDb.put(record, NamedBlobState.READY, true).get();
+ }
+
+ // null prefix triggers LIST_ALL_QUERY (or LIST_ALL_SQL under option 4). This is the path
+ // an empty-prefix S3 LIST collapses to via parseS3.
+ Page