This is an automated email from the ASF dual-hosted git repository.

jojochuang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 2976b85885a HDDS-16760. Remove the per-call InfoBucket from rooted-ofs 
listStatus
2976b85885a is described below

commit 2976b85885a54ba7a3da6fc429c4cdaa299a3d54
Author: Andrey Yarovoy <[email protected]>
AuthorDate: Wed Oct 14 15:28:03 2026 -0400

    HDDS-16760. Remove the per-call InfoBucket from rooted-ofs listStatus
---
 .../apache/hadoop/ozone/OzoneManagerVersion.java   |   5 +
 .../ozone/client/protocol/ClientProtocol.java      |  12 +
 .../client/protocol/ListStatusLightOptions.java    |  20 +-
 .../apache/hadoop/ozone/client/rpc/RpcClient.java  |  19 +-
 .../apache/hadoop/ozone/om/helpers/OmKeyArgs.java  |  19 ++
 ...OzoneManagerProtocolClientSideTranslatorPB.java |   6 +
 .../ozone/AbstractRootedOzoneFileSystemTest.java   |  48 ++++
 .../fs/ozone/TestOfsListStatusRpcBenchmark.java    | 308 +++++++++++++++++++++
 .../src/main/proto/OmClientProtocol.proto          |   3 +
 .../apache/hadoop/ozone/om/OmMetadataReader.java   |  51 ++--
 .../protocolPB/OzoneManagerRequestHandler.java     |   4 +-
 .../hadoop/ozone/om/TestOMMetadataReader.java      |  71 +++++
 .../protocolPB/TestOzoneManagerRequestHandler.java |  36 +++
 .../ozone/BasicRootedOzoneClientAdapterImpl.java   |  65 ++++-
 ...estBasicRootedOzoneClientAdapterListStatus.java | 277 ++++++++++++++++++
 .../hadoop/ozone/client/ClientProtocolStub.java    |   5 +
 16 files changed, 910 insertions(+), 39 deletions(-)

diff --git 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneManagerVersion.java
 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneManagerVersion.java
index bce3ab2e1e9..be5ebc7efdd 100644
--- 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneManagerVersion.java
+++ 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneManagerVersion.java
@@ -73,6 +73,11 @@ public enum OzoneManagerVersion implements ComponentVersion {
           + "server-side, so file system clients no longer need the "
           + "client-side InfoBucket layout check for getFileChecksum"),
   ZDU(100, "OzoneManager version that supports zero downtime upgrade"),
+  LIST_STATUS_REJECTS_OBS(101,
+      "OzoneManager version that rejects listStatus on OBJECT_STORE buckets "
+          + "server-side when the request sets requireFileSystemLayout, so 
file "
+          + "system clients no longer need the client-side InfoBucket layout "
+          + "check for listStatus"),
 
   UNKNOWN_VERSION(-1, "Used when a version cannot be deserialized to any 
version recognized by this" +
       " component, which may indicate it came from a component in a newer 
version");
diff --git 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
index 96247bb2ad1..f6eee8909a8 100644
--- 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
+++ 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
@@ -1252,6 +1252,18 @@ List<OzoneFileStatus> listStatus(String volumeName, 
String bucketName,
       String keyName, boolean recursive, String startKey,
       long numEntries, boolean allowPartialPrefixes) throws IOException;
 
+  /**
+   * List the status for a file or a directory and its contents, returning the
+   * full file status. Takes the same options as {@link #listStatusLight}.
+   *
+   * @param options Encapsulates volume, bucket, key, recursive, startKey,
+   *                numEntries, allowPartialPrefixes, and whether OM must 
reject
+   *                an OBJECT_STORE bucket.
+   * @return list of file status
+   */
+  List<OzoneFileStatus> listStatus(ListStatusLightOptions options)
+      throws IOException;
+
   /**
    * Lightweight listStatus API.
    *
diff --git 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java
 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java
index 28fff17bbf5..240a0364745 100644
--- 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java
+++ 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ListStatusLightOptions.java
@@ -36,6 +36,9 @@ public final class ListStatusLightOptions {
   // When keyName is empty (root listing), this is the original S3/list
   // prefix for STS auth. Enables LIST check on this prefix instead of "*".
   private final String listPrefix;
+  // Set by file system clients: OM rejects the listing if the resolved bucket
+  // layout is OBJECT_STORE.
+  private final boolean requireFileSystemLayout;
 
   private ListStatusLightOptions(Builder b) {
     this.volumeName = b.volumeName;
@@ -46,6 +49,7 @@ private ListStatusLightOptions(Builder b) {
     this.numEntries = b.numEntries;
     this.allowPartialPrefixes = b.allowPartialPrefixes;
     this.listPrefix = b.listPrefix;
+    this.requireFileSystemLayout = b.requireFileSystemLayout;
   }
 
   public String getVolumeName() {
@@ -80,6 +84,10 @@ public String getListPrefix() {
     return listPrefix;
   }
 
+  public boolean isRequireFileSystemLayout() {
+    return requireFileSystemLayout;
+  }
+
   public static Builder builder() {
     return new Builder();
   }
@@ -113,6 +121,7 @@ public static final class Builder {
     private long numEntries;
     private boolean allowPartialPrefixes;
     private String listPrefix;
+    private boolean requireFileSystemLayout;
 
     public Builder setVolumeName(String volumeName) {
       this.volumeName = volumeName;
@@ -154,6 +163,11 @@ public Builder setListPrefix(String listPrefix) {
       return this;
     }
 
+    public Builder setRequireFileSystemLayout(boolean requireFileSystemLayout) 
{
+      this.requireFileSystemLayout = requireFileSystemLayout;
+      return this;
+    }
+
     public ListStatusLightOptions build() {
       return new ListStatusLightOptions(this);
     }
@@ -169,7 +183,8 @@ public boolean equals(Object o) {
     }
     final ListStatusLightOptions that = (ListStatusLightOptions) o;
     return recursive == that.recursive && numEntries == that.numEntries &&
-        allowPartialPrefixes == that.allowPartialPrefixes && 
Objects.equals(volumeName, that.volumeName) &&
+        allowPartialPrefixes == that.allowPartialPrefixes &&
+        requireFileSystemLayout == that.requireFileSystemLayout && 
Objects.equals(volumeName, that.volumeName) &&
         Objects.equals(bucketName, that.bucketName) && Objects.equals(keyName, 
that.keyName) &&
         Objects.equals(startKey, that.startKey) && Objects.equals(listPrefix, 
that.listPrefix);
   }
@@ -177,6 +192,7 @@ public boolean equals(Object o) {
   @Override
   public int hashCode() {
     return Objects.hash(
-        volumeName, bucketName, keyName, recursive, startKey, numEntries, 
allowPartialPrefixes, listPrefix);
+        volumeName, bucketName, keyName, recursive, startKey, numEntries, 
allowPartialPrefixes, listPrefix,
+        requireFileSystemLayout);
   }
 }
diff --git 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
index 438ce126978..21e430e75b3 100644
--- 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
+++ 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
@@ -2535,6 +2535,14 @@ private OmKeyArgs prepareOmKeyArgs(String volumeName, 
String bucketName,
     return builder.build();
   }
 
+  private OmKeyArgs prepareListStatusKeyArgs(ListStatusLightOptions options) {
+    final OmKeyArgs keyArgs = prepareOmKeyArgs(
+        options.getVolumeName(), options.getBucketName(), 
options.getKeyName(), options.getListPrefix());
+    return options.isRequireFileSystemLayout()
+        ? keyArgs.toBuilder().setRequireFileSystemLayout(true).build()
+        : keyArgs;
+  }
+
   @Override
   public OzoneDataStreamOutput createStreamFile(String volumeName,
       String bucketName, String keyName, long size,
@@ -2576,11 +2584,18 @@ public List<OzoneFileStatus> listStatus(String 
volumeName, String bucketName,
             allowPartialPrefixes);
   }
 
+  @Override
+  public List<OzoneFileStatus> listStatus(ListStatusLightOptions options)
+      throws IOException {
+    final OmKeyArgs keyArgs = prepareListStatusKeyArgs(options);
+    return ozoneManagerClient.listStatus(keyArgs, options.isRecursive(), 
options.getStartKey(),
+        options.getNumEntries(), options.isAllowPartialPrefixes());
+  }
+
   @Override
   public List<OzoneFileStatusLight> listStatusLight(ListStatusLightOptions 
options)
       throws IOException {
-    final OmKeyArgs keyArgs = prepareOmKeyArgs(
-        options.getVolumeName(), options.getBucketName(), 
options.getKeyName(), options.getListPrefix());
+    final OmKeyArgs keyArgs = prepareListStatusKeyArgs(options);
     if (OzoneManagerVersion.LIGHTWEIGHT_LIST_STATUS.isSupportedBy(omVersion)) {
       return ozoneManagerClient.listStatusLight(
           keyArgs, options.isRecursive(), options.getStartKey(), 
options.getNumEntries(),
diff --git 
a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java
 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java
index 03ff4bd8f61..2280845cd3b 100644
--- 
a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java
+++ 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/OmKeyArgs.java
@@ -64,6 +64,9 @@ public final class OmKeyArgs extends WithMetadata implements 
Auditable {
   // Original S3/list prefix when keyName is empty (root listing). Used for STS
   // auth to check LIST on this prefix instead of "*".
   private final String listPrefix;
+  // Set by file system clients so that OM rejects a listing on an
+  // OBJECT_STORE bucket.
+  private final boolean requireFileSystemLayout;
   private final String expectedETag;
   private final boolean derivedKeyPiggyBacking;
 
@@ -88,6 +91,7 @@ private OmKeyArgs(Builder b) {
     this.tags = b.tags.build();
     this.expectedDataGeneration = b.expectedDataGeneration;
     this.listPrefix = b.listPrefix;
+    this.requireFileSystemLayout = b.requireFileSystemLayout;
     this.expectedETag = b.expectedETag;
     this.derivedKeyPiggyBacking = b.derivedKeyPiggyBacking;
   }
@@ -180,6 +184,14 @@ public String getListPrefix() {
     return listPrefix;
   }
 
+  /**
+   * Whether OM must reject the request if the resolved bucket layout is
+   * OBJECT_STORE, because the caller needs file system semantics.
+   */
+  public boolean isRequireFileSystemLayout() {
+    return requireFileSystemLayout;
+  }
+
   public String getExpectedETag() {
     return expectedETag;
   }
@@ -262,6 +274,7 @@ public static class Builder extends WithMetadata.Builder {
     private final MapBuilder<String, String> tags;
     private Long expectedDataGeneration = null;
     private String listPrefix = null;
+    private boolean requireFileSystemLayout;
     private String expectedETag;
     private boolean derivedKeyPiggyBacking;
 
@@ -314,6 +327,7 @@ public Builder(OmKeyArgs obj) {
       this.tags = MapBuilder.of(obj.tags);
       this.acls = AclListBuilder.of(obj.acls);
       this.listPrefix = obj.listPrefix;
+      this.requireFileSystemLayout = obj.requireFileSystemLayout;
     }
 
     public Builder setVolumeName(String volume) {
@@ -452,6 +466,11 @@ public Builder setListPrefix(String prefix) {
       return this;
     }
 
+    public Builder setRequireFileSystemLayout(boolean require) {
+      this.requireFileSystemLayout = require;
+      return this;
+    }
+
     public Builder setExpectedETag(String eTag) {
       this.expectedETag = eTag;
       return this;
diff --git 
a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
index 9489c9cf5fc..5c46210c272 100644
--- 
a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
+++ 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
@@ -2650,6 +2650,9 @@ public List<OzoneFileStatus> listStatus(OmKeyArgs args, 
boolean recursive,
 
     final ListStatusRequest.Builder listStatusRequestBuilder = 
createListStatusRequestBuilder(
         keyArgs, recursive, startKey, numEntries, allowPartialPrefixes);
+    if (args.isRequireFileSystemLayout()) {
+      listStatusRequestBuilder.setRequireFileSystemLayout(true);
+    }
 
     OMRequest omRequest = createOMRequest(Type.ListStatus)
         .setListStatusRequest(listStatusRequestBuilder.build())
@@ -2683,6 +2686,9 @@ public List<OzoneFileStatusLight> 
listStatusLight(OmKeyArgs args,
     if (listPrefix != null && !listPrefix.isEmpty()) {
       listStatusRequestBuilder.setListPrefix(listPrefix);
     }
+    if (args.isRequireFileSystemLayout()) {
+      listStatusRequestBuilder.setRequireFileSystemLayout(true);
+    }
 
     OMRequest omRequest = createOMRequest(Type.ListStatusLight)
         .setListStatusRequest(listStatusRequestBuilder.build())
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/AbstractRootedOzoneFileSystemTest.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/AbstractRootedOzoneFileSystemTest.java
index 83661bd3d55..99234920214 100644
--- 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/AbstractRootedOzoneFileSystemTest.java
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/AbstractRootedOzoneFileSystemTest.java
@@ -1942,6 +1942,54 @@ void testGetFileChecksumOnNonExistentFile() throws 
Exception {
         () -> fs.getFileChecksum(missing));
   }
 
+  @Test
+  void testListStatusDoesNotTriggerInfoBucket() throws Exception {
+    Path dir = new Path(bucketPath, "list-rpc-" + 
RandomStringUtils.secure().nextAlphanumeric(5));
+    ContractTestUtils.touch(fs, new Path(dir, "file"));
+
+    OMMetrics metrics = getOMMetrics();
+    long bucketInfosBefore = metrics.getNumBucketInfos();
+
+    assertEquals(1, fs.listStatus(dir).length);
+    assertTrue(fs.listStatusIterator(dir).hasNext());
+
+    assertEquals(bucketInfosBefore, metrics.getNumBucketInfos(), "listStatus 
must not trigger InfoBucket");
+  }
+
+  @Test
+  void testListStatusRejectsObsBucket() throws Exception {
+    OzoneBucket obsBucket = DataTestUtil.createVolumeAndBucket(client, 
BucketLayout.OBJECT_STORE);
+    DataTestUtil.createKey(obsBucket, "obs-key", 
"data".getBytes(StandardCharsets.UTF_8));
+    Path obsBucketPath = new Path(new Path(OZONE_URI_DELIMITER, 
obsBucket.getVolumeName()), obsBucket.getName());
+
+    OMMetrics metrics = getOMMetrics();
+    long bucketInfosBefore = metrics.getNumBucketInfos();
+
+    IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class,
+        () -> fs.listStatus(obsBucketPath));
+    assertThat(exception.getMessage()).contains(obsBucket.getName());
+    assertThat(exception.getMessage()).contains("OBJECT_STORE");
+    assertEquals(bucketInfosBefore, metrics.getNumBucketInfos(), "listStatus 
must not trigger InfoBucket");
+  }
+
+  @Test
+  void testListStatusOrphanLinkBucketIsEmpty() throws Exception {
+    OzoneBucket sourceBucket = DataTestUtil.createVolumeAndBucket(client, 
bucketLayout);
+    OzoneVolume sourceVolume = 
objectStore.getVolume(sourceBucket.getVolumeName());
+    String linkBucketName = "orphan-link-" + 
RandomStringUtils.secure().nextNumeric(5);
+    createLinkBucket(sourceVolume.getName(), linkBucketName, 
sourceVolume.getName(), sourceBucket.getName());
+    sourceVolume.deleteBucket(sourceBucket.getName());
+    Path linkPath = new Path(new Path(OZONE_URI_DELIMITER, 
sourceVolume.getName()), linkBucketName);
+
+    assertEquals(0, fs.listStatus(linkPath).length);
+    assertFalse(fs.listStatusIterator(linkPath).hasNext());
+
+    // A bucket that does not exist still fails, unlike the orphan link.
+    Path missingBucket = new Path(new Path(OZONE_URI_DELIMITER, 
sourceVolume.getName()), "missing-bucket");
+    OMException exception = assertThrows(OMException.class, () -> 
fs.listStatus(missingBucket));
+    assertEquals(BUCKET_NOT_FOUND, exception.getResult());
+  }
+
   @Test
   void testGetFileStatus() throws Exception {
     String volumeNameLocal = getRandomNonExistVolumeName();
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOfsListStatusRpcBenchmark.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOfsListStatusRpcBenchmark.java
new file mode 100644
index 00000000000..1266766248a
--- /dev/null
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOfsListStatusRpcBenchmark.java
@@ -0,0 +1,308 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hadoop.fs.ozone;
+
+import static 
org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_DEFAULT_NAME_KEY;
+import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_ADDRESS_KEY;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.contract.ContractTestUtils;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.utils.IOUtils;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.client.BucketArgs;
+import org.apache.hadoop.ozone.client.ObjectStore;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.client.OzoneVolume;
+import org.apache.hadoop.ozone.om.OMConfigKeys;
+import org.apache.hadoop.ozone.om.OMMetrics;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Benchmark for the OFS listStatus OM RPC count (HDDS-16760).
+ *
+ * <p>The same workload runs unchanged on the baseline ({@code master}: 
InfoBucket + ListStatusLight per call)
+ * and on the fix (a single ListStatusLight that asks OM to reject an 
OBJECT_STORE bucket). It follows
+ * {@link TestOfsGetFileChecksumRpcBenchmark}: {@link #NUM_BUCKETS} buckets 
are listed
+ * {@link #ACCESSES_PER_BUCKET} times each from a shuffled access sequence, 
single-threaded and with
+ * {@link #CONCURRENT_THREADS} client threads. Each listed directory holds 
{@link #FILES_PER_DIR} files, so the
+ * entries fit in one page; {@code BasicRootedOzoneFileSystem} still lists 
until a page adds nothing, so each
+ * listStatus makes {@link #LISTINGS_PER_CALL} adapter listings on both arms.
+ */
+@Tag("benchmark")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestOfsListStatusRpcBenchmark {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestOfsListStatusRpcBenchmark.class);
+
+  /** Distinct buckets in the working set. */
+  private static final int NUM_BUCKETS = 200;
+  /** listStatus calls per bucket. */
+  private static final int ACCESSES_PER_BUCKET = 10;
+  private static final int TOTAL_CALLS = NUM_BUCKETS * ACCESSES_PER_BUCKET;
+  private static final long SHUFFLE_SEED = 20250831L;
+  /** Client threads issuing listStatus in the concurrent comparison. */
+  private static final int CONCURRENT_THREADS = 10;
+  /** Entries in each listed directory. */
+  private static final int FILES_PER_DIR = 10;
+  /** Adapter listings per listStatus: the page with the entries, then one 
that adds nothing. */
+  private static final int LISTINGS_PER_CALL = 2;
+
+  private MiniOzoneCluster cluster;
+  private OzoneClient client;
+  private OzoneConfiguration conf;
+  private String rootPath;
+
+  private final List<Path> accessSequence = new ArrayList<>(TOTAL_CALLS);
+
+  @BeforeAll
+  void init() throws IOException, InterruptedException, TimeoutException {
+    conf = new OzoneConfiguration();
+    conf.set(OMConfigKeys.OZONE_DEFAULT_BUCKET_LAYOUT, 
BucketLayout.FILE_SYSTEM_OPTIMIZED.name());
+    cluster = MiniOzoneCluster.newBuilder(conf)
+        .setNumDatanodes(3)
+        .build();
+    cluster.waitForClusterToBeReady();
+    client = cluster.newClient();
+    rootPath = String.format("%s://%s/", OzoneConsts.OZONE_OFS_URI_SCHEME, 
conf.get(OZONE_OM_ADDRESS_KEY));
+
+    ObjectStore objectStore = client.getObjectStore();
+    String volName = "benchvol";
+    objectStore.createVolume(volName);
+    OzoneVolume volume = objectStore.getVolume(volName);
+    BucketArgs fsoArgs = 
BucketArgs.newBuilder().setBucketLayout(BucketLayout.FILE_SYSTEM_OPTIMIZED).build();
+    try (FileSystem setupFs = FileSystem.newInstance(URI.create(rootPath), 
conf)) {
+      for (int i = 0; i < NUM_BUCKETS; i++) {
+        String buckName = "benchbucket-" + i;
+        volume.createBucket(buckName, fsoArgs);
+        Path dir = new Path("/" + volName + "/" + buckName + "/dir");
+        for (int f = 0; f < FILES_PER_DIR; f++) {
+          ContractTestUtils.touch(setupFs, new Path(dir, "file-" + f));
+        }
+        for (int a = 0; a < ACCESSES_PER_BUCKET; a++) {
+          accessSequence.add(dir);
+        }
+      }
+      
assertThat(setupFs.listStatus(accessSequence.get(0))).hasSize(FILES_PER_DIR);
+    }
+    Collections.shuffle(accessSequence, new Random(SHUFFLE_SEED));
+  }
+
+  @AfterAll
+  void shutdown() {
+    IOUtils.closeQuietly(client);
+    if (cluster != null) {
+      cluster.shutdown();
+    }
+  }
+
+  @Test
+  void benchmarkListStatus() throws Exception {
+    // Prime OM-side state and the JVM so the measured run pays no cold-start.
+    runWorkload(1);
+
+    Result single = runWorkload(1);
+    Result concurrent = runWorkload(CONCURRENT_THREADS);
+
+    assertWorkloadInvariants(single);
+    assertWorkloadInvariants(concurrent);
+
+    logResult("single-threaded", single);
+    logResult(CONCURRENT_THREADS + " concurrent threads", concurrent);
+  }
+
+  /**
+   * Invariants that hold on both the baseline and the fixed code, so the 
identical benchmark passes in either
+   * worktree: every listStatus issues {@link #LISTINGS_PER_CALL} listing 
RPCs, and the bucket and volume RPCs stay
+   * within one per listing.
+   */
+  private void assertWorkloadInvariants(Result r) {
+    long listings = (long) TOTAL_CALLS * LISTINGS_PER_CALL;
+    assertThat(r.listStatusRpcs).isEqualTo(listings);
+    assertThat(r.bucketInfoRpcs).isBetween(0L, listings);
+    assertThat(r.volumeInfoRpcs).isBetween(0L, listings);
+  }
+
+  private void logResult(String label, Result r) {
+    long omRpcs = r.volumeInfoRpcs + r.bucketInfoRpcs + r.listStatusRpcs;
+    LOG.info(String.format("%n"
+            + "BENCHRESULT OFS listStatus OM RPC benchmark (HDDS-16760) - %s%n"
+            + "  buckets=%d, accesses/bucket=%d, total listStatus=%d, 
threads=%d, entries/dir=%d%n"
+            + "  
------------------------------------------------------------%n"
+            + "  InfoVolume RPCs             : %d%n"
+            + "  InfoBucket RPCs             : %d%n"
+            + "  ListStatus RPCs             : %d%n"
+            + "  total OM RPCs               : %d%n"
+            + "  OM RPCs per call            : %.2f%n"
+            + "  
------------------------------------------------------------%n"
+            + "  latency mean                : %.3f ms%n"
+            + "  latency p50                 : %.3f ms%n"
+            + "  latency p90                 : %.3f ms%n"
+            + "  latency p99                 : %.3f ms%n"
+            + "  latency max                 : %.3f ms%n"
+            + "  throughput                  : %.1f listStatus/s%n",
+        label, NUM_BUCKETS, ACCESSES_PER_BUCKET, TOTAL_CALLS, r.threads, 
FILES_PER_DIR,
+        r.volumeInfoRpcs, r.bucketInfoRpcs, r.listStatusRpcs,
+        omRpcs, omRpcs / (double) TOTAL_CALLS,
+        millis(r.mean()), millis(r.percentile(0.50)),
+        millis(r.percentile(0.90)), millis(r.percentile(0.99)),
+        millis(r.max()), r.opsPerSecond()));
+  }
+
+  private static double millis(double nanos) {
+    return nanos / (double) TimeUnit.MILLISECONDS.toNanos(1);
+  }
+
+  private Result runWorkload(int threads) throws IOException, 
InterruptedException {
+    OzoneConfiguration runConf = new OzoneConfiguration(conf);
+    runConf.set(FS_DEFAULT_NAME_KEY, rootPath);
+
+    OMMetrics metrics = cluster.getOzoneManager().getMetrics();
+    long[] latencyNanos = new long[TOTAL_CALLS];
+    // A new FileSystem instance per run, so each run starts with no 
client-side state carried over.
+    try (FileSystem fs = FileSystem.newInstance(URI.create(rootPath), 
runConf)) {
+      long volumeInfosBefore = metrics.getNumVolumeInfos();
+      long bucketInfosBefore = metrics.getNumBucketInfos();
+      long listStatusBefore = metrics.getNumListStatus();
+      long elapsedNanos = threads == 1
+          ? runSequential(fs, latencyNanos)
+          : runConcurrent(fs, threads, latencyNanos);
+      return new Result(threads,
+          metrics.getNumVolumeInfos() - volumeInfosBefore,
+          metrics.getNumBucketInfos() - bucketInfosBefore,
+          metrics.getNumListStatus() - listStatusBefore,
+          elapsedNanos, latencyNanos);
+    }
+  }
+
+  private long runSequential(FileSystem fs, long[] latencyNanos) throws 
IOException {
+    long startNanos = System.nanoTime();
+    int i = 0;
+    for (Path path : accessSequence) {
+      long callStart = System.nanoTime();
+      fs.listStatus(path);
+      latencyNanos[i++] = System.nanoTime() - callStart;
+    }
+    return System.nanoTime() - startNanos;
+  }
+
+  private long runConcurrent(FileSystem fs, int threads, long[] latencyNanos)
+      throws IOException, InterruptedException {
+    ExecutorService pool = Executors.newFixedThreadPool(threads);
+    CountDownLatch startGate = new CountDownLatch(1);
+    CountDownLatch doneGate = new CountDownLatch(threads);
+    AtomicReference<IOException> failure = new AtomicReference<>();
+    // Disjoint, contiguous slices of the shuffled sequence; each thread 
writes only its own latency indices, so no
+    // synchronization is needed per call.
+    int chunk = (TOTAL_CALLS + threads - 1) / threads;
+    for (int t = 0; t < threads; t++) {
+      final int from = t * chunk;
+      final int to = Math.min(TOTAL_CALLS, from + chunk);
+      pool.submit(() -> {
+        try {
+          startGate.await();
+          for (int i = from; i < to; i++) {
+            long callStart = System.nanoTime();
+            fs.listStatus(accessSequence.get(i));
+            latencyNanos[i] = System.nanoTime() - callStart;
+          }
+        } catch (IOException e) {
+          failure.compareAndSet(null, e);
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+        } finally {
+          doneGate.countDown();
+        }
+      });
+    }
+    long startNanos = System.nanoTime();
+    startGate.countDown();
+    doneGate.await();
+    long elapsedNanos = System.nanoTime() - startNanos;
+    pool.shutdownNow();
+    if (failure.get() != null) {
+      throw failure.get();
+    }
+    return elapsedNanos;
+  }
+
+  private static final class Result {
+    private final int threads;
+    private final long volumeInfoRpcs;
+    private final long bucketInfoRpcs;
+    private final long listStatusRpcs;
+    private final long elapsedNanos;
+    private final long[] sortedLatencyNanos;
+
+    Result(int threads, long volumeInfoRpcs, long bucketInfoRpcs, long 
listStatusRpcs, long elapsedNanos,
+        long[] latencyNanos) {
+      this.threads = threads;
+      this.volumeInfoRpcs = volumeInfoRpcs;
+      this.bucketInfoRpcs = bucketInfoRpcs;
+      this.listStatusRpcs = listStatusRpcs;
+      this.elapsedNanos = elapsedNanos;
+      this.sortedLatencyNanos = latencyNanos.clone();
+      Arrays.sort(this.sortedLatencyNanos);
+    }
+
+    double opsPerSecond() {
+      return TOTAL_CALLS / (elapsedNanos / (double) 
TimeUnit.SECONDS.toNanos(1));
+    }
+
+    double mean() {
+      long sum = 0;
+      for (long l : sortedLatencyNanos) {
+        sum += l;
+      }
+      return sum / (double) sortedLatencyNanos.length;
+    }
+
+    double percentile(double q) {
+      int idx = (int) Math.ceil(q * sortedLatencyNanos.length) - 1;
+      idx = Math.max(0, Math.min(sortedLatencyNanos.length - 1, idx));
+      return sortedLatencyNanos[idx];
+    }
+
+    double max() {
+      return sortedLatencyNanos[sortedLatencyNanos.length - 1];
+    }
+  }
+}
diff --git 
a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto 
b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
index 1445e1bb2bf..529ef0ffd85 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -1379,6 +1379,9 @@ message ListStatusRequest {
     // When keyArgs.keyName is empty (root listing), this is the original 
S3/list
     // prefix for STS auth. Enables LIST check on this prefix instead of "*".
     optional string listPrefix = 6;
+    // Set by file system clients: OM rejects the listing with
+    // NOT_SUPPORTED_OPERATION if the resolved bucket layout is OBJECT_STORE.
+    optional bool requireFileSystemLayout = 7;
 }
 
 message ListStatusResponse {
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java
index 4435cf78b82..dfefbef845f 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OmMetadataReader.java
@@ -252,6 +252,9 @@ public List<OzoneFileStatus> listStatus(OmKeyArgs args, 
boolean recursive,
     args = bucket.update(args);
 
     try {
+      if (args.isRequireFileSystemLayout()) {
+        validateFileSystemLayout(bucket);
+      }
       if (isAclEnabled) {
         checkListStatusAcls(args, bucket);
       }
@@ -288,6 +291,9 @@ public List<OzoneFileStatusLight> listStatusLight(OmKeyArgs 
args,
     OmKeyArgs resolvedArgs = bucket.update(args);
 
     try {
+      if (resolvedArgs.isRequireFileSystemLayout()) {
+        validateFileSystemLayout(bucket);
+      }
       if (isAclEnabled) {
         checkListStatusAcls(resolvedArgs, bucket);
       }
@@ -310,6 +316,25 @@ startKey, maxListingPageSize, getClientAddress(),
     }
   }
   
+  /**
+   * Rejects file system reads on a bucket whose link-resolved layout is
+   * OBJECT_STORE, so file system clients need no InfoBucket to check it.
+   * listStatus and listStatusLight call it only when the client asked for it,
+   * because they also serve object store callers (S3 shallow key listing).
+   */
+  private static void validateFileSystemLayout(ResolvedBucket bucket) throws 
OMException {
+    if (bucket.bucketLayout() != null) {
+      try {
+        OzoneFSUtils.validateBucketLayout(bucket.requestedBucket(), 
bucket.bucketLayout());
+      } catch (IllegalArgumentException e) {
+        // Convert to an OMException so it is returned to the client as a
+        // normal (non-retryable) RPC response instead of escaping the read
+        // handler's IOException catch and triggering a client retry storm.
+        throw new OMException(e.getMessage(), 
ResultCodes.NOT_SUPPORTED_OPERATION);
+      }
+    }
+  }
+
   @Override
   public OzoneFileStatus getFileStatus(OmKeyArgs args) throws IOException {
     ResolvedBucket bucket = ozoneManager.resolveBucketLink(args);
@@ -320,18 +345,7 @@ public OzoneFileStatus getFileStatus(OmKeyArgs args) 
throws IOException {
     args = bucket.update(args);
 
     try {
-      if (bucket.bucketLayout() != null) {
-        try {
-          OzoneFSUtils.validateBucketLayout(bucket.requestedBucket(),
-              bucket.bucketLayout());
-        } catch (IllegalArgumentException e) {
-          // Convert to an OMException so it is returned to the client as a
-          // normal (non-retryable) RPC response instead of escaping the read
-          // handler's IOException catch and triggering a client retry storm.
-          throw new OMException(e.getMessage(),
-              ResultCodes.NOT_SUPPORTED_OPERATION);
-        }
-      }
+      validateFileSystemLayout(bucket);
       if (isAclEnabled) {
         checkAcls(getResourceType(args), StoreType.OZONE, ACLType.READ, 
bucket, args.getKeyName());
       }
@@ -361,18 +375,7 @@ public OmKeyInfo lookupFile(OmKeyArgs args) throws 
IOException {
     args = normalizeKeyArgs(bucket.update(args), bucket);
 
     try {
-      if (bucket.bucketLayout() != null) {
-        try {
-          OzoneFSUtils.validateBucketLayout(bucket.requestedBucket(),
-              bucket.bucketLayout());
-        } catch (IllegalArgumentException e) {
-          // Convert to an OMException so it is returned to the client as a
-          // normal (non-retryable) RPC response instead of escaping the read
-          // handler's IOException catch and triggering a client retry storm.
-          throw new OMException(e.getMessage(),
-              ResultCodes.NOT_SUPPORTED_OPERATION);
-        }
-      }
+      validateFileSystemLayout(bucket);
       if (isAclEnabled) {
         checkAcls(ResourceType.KEY, StoreType.OZONE, ACLType.READ,
             bucket, args.getKeyName());
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
index b862bd03662..a0a4d400dfb 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerRequestHandler.java
@@ -1332,6 +1332,7 @@ private ListStatusResponse listStatus(
         .setKeyName(keyArgs.getKeyName())
         .setLatestVersionLocation(keyArgs.getLatestVersionLocation())
         .setHeadOp(keyArgs.getHeadOp())
+        .setRequireFileSystemLayout(request.getRequireFileSystemLayout())
         .build();
     boolean allowPartialPrefixes =
         request.hasAllowPartialPrefix() && request.getAllowPartialPrefix();
@@ -1356,7 +1357,8 @@ private ListStatusLightResponse 
listStatusLight(ListStatusRequest request) throw
         .setKeyName(keyArgs.getKeyName())
         .setSortDatanodesInPipeline(false)
         .setLatestVersionLocation(true)
-        .setHeadOp(keyArgs.getHeadOp());
+        .setHeadOp(keyArgs.getHeadOp())
+        .setRequireFileSystemLayout(request.getRequireFileSystemLayout());
     if (request.hasListPrefix() && !request.getListPrefix().isEmpty()) {
       omKeyArgsBuilder.setListPrefix(request.getListPrefix());
     }
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java
index 01a5bad1808..4343480376f 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/TestOMMetadataReader.java
@@ -27,6 +27,8 @@
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -900,4 +902,73 @@ public void lookupFileAllowsLegacyLayout() throws 
Exception {
     assertEquals(expectedKeyInfo, reader.lookupFile(keyArgs));
     verify(keyManager).lookupFile(any(OmKeyArgs.class), anyString());
   }
+
+  private static OmMetadataReader newListStatusReader(KeyManager keyManager, 
String bucketName,
+      BucketLayout layout) throws IOException {
+    OzoneManager ozoneManager = mock(OzoneManager.class);
+    when(ozoneManager.getAclsEnabled()).thenReturn(false);
+    when(ozoneManager.getConfiguration()).thenReturn(new OzoneConfiguration());
+    
when(ozoneManager.getBucketManager()).thenReturn(mock(BucketManager.class));
+    
when(ozoneManager.getVolumeManager()).thenReturn(mock(VolumeManager.class));
+    
when(ozoneManager.getPerfMetrics()).thenReturn(mock(OMPerformanceMetrics.class));
+    when(ozoneManager.resolveBucketLink(any(OmKeyArgs.class)))
+        .thenReturn(new ResolvedBucket("vol", bucketName, "vol", bucketName, 
"owner", layout));
+    return new OmMetadataReader(keyManager, mock(PrefixManager.class), 
ozoneManager, mock(org.slf4j.Logger.class),
+        mock(AuditLogger.class), mock(OmMetadataReaderMetrics.class), null);
+  }
+
+  private static OmKeyArgs listStatusArgs(String bucketName, boolean 
requireFileSystemLayout) {
+    return new OmKeyArgs.Builder()
+        .setVolumeName("vol")
+        .setBucketName(bucketName)
+        .setKeyName("dir")
+        .setRequireFileSystemLayout(requireFileSystemLayout)
+        .build();
+  }
+
+  @Test
+  public void listStatusRejectsObjectStoreLayout() throws Exception {
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newListStatusReader(keyManager, "obs-bucket", 
BucketLayout.OBJECT_STORE);
+
+    OMException exception = assertThrows(OMException.class,
+        () -> reader.listStatus(listStatusArgs("obs-bucket", true), false, "", 
100, false));
+    assertEquals(ResultCodes.NOT_SUPPORTED_OPERATION, exception.getResult());
+    assertTrue(exception.getMessage().contains("obs-bucket"));
+    assertTrue(exception.getMessage().contains("OBJECT_STORE"));
+
+    exception = assertThrows(OMException.class,
+        () -> reader.listStatusLight(listStatusArgs("obs-bucket", true), 
false, "", 100, false));
+    assertEquals(ResultCodes.NOT_SUPPORTED_OPERATION, exception.getResult());
+
+    verify(keyManager, never()).listStatus(any(), anyBoolean(), any(), 
anyLong(), any(), anyBoolean());
+    verify(keyManager, never()).listStatus(any(), anyBoolean(), any(), 
anyLong(), any(), anyBoolean(),
+        anyBoolean());
+  }
+
+  @Test
+  public void listStatusAllowsLegacyLayout() throws Exception {
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newListStatusReader(keyManager, "legacy-bucket", 
BucketLayout.LEGACY);
+
+    assertTrue(reader.listStatus(listStatusArgs("legacy-bucket", true), false, 
"", 100, false).isEmpty());
+    assertTrue(reader.listStatusLight(listStatusArgs("legacy-bucket", true), 
false, "", 100, false).isEmpty());
+
+    verify(keyManager).listStatus(any(OmKeyArgs.class), eq(false), eq(""), 
anyLong(), any(), eq(false));
+    verify(keyManager).listStatus(any(OmKeyArgs.class), eq(false), eq(""), 
anyLong(), any(), eq(false), eq(false));
+  }
+
+  @Test
+  public void listStatusServesObjectStoreLayoutWhenNotRequested() throws 
Exception {
+    // Object store callers, such as S3 shallow key listing and OM trash, list
+    // OBJECT_STORE buckets without asking for a file system layout.
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newListStatusReader(keyManager, "obs-bucket", 
BucketLayout.OBJECT_STORE);
+
+    assertTrue(reader.listStatus(listStatusArgs("obs-bucket", false), false, 
"", 100, false).isEmpty());
+    assertTrue(reader.listStatusLight(listStatusArgs("obs-bucket", false), 
false, "", 100, false).isEmpty());
+
+    verify(keyManager).listStatus(any(OmKeyArgs.class), eq(false), eq(""), 
anyLong(), any(), eq(false));
+    verify(keyManager).listStatus(any(OmKeyArgs.class), eq(false), eq(""), 
anyLong(), any(), eq(false), eq(false));
+  }
 }
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/protocolPB/TestOzoneManagerRequestHandler.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/protocolPB/TestOzoneManagerRequestHandler.java
index 0f8d9602538..e50547f93b3 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/protocolPB/TestOzoneManagerRequestHandler.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/protocolPB/TestOzoneManagerRequestHandler.java
@@ -307,6 +307,42 @@ public void testListStatusResponseSize(int resultSize) 
throws IOException {
     }
   }
 
+  /**
+   * ListStatus and ListStatusLight must forward requireFileSystemLayout from
+   * the request into the OmKeyArgs handed to the OM.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testListStatusForwardsRequireFileSystemLayout(boolean require) 
throws IOException {
+    OzoneManagerRequestHandler requestHandler = getRequestHandler(10);
+    OzoneManager ozoneManager = requestHandler.getOzoneManager();
+    OzoneManagerProtocolProtos.ListStatusRequest listStatusRequest =
+        OzoneManagerProtocolProtos.ListStatusRequest.newBuilder()
+            .setKeyArgs(OzoneManagerProtocolProtos.KeyArgs.newBuilder()
+                
.setVolumeName("volume").setBucketName("bucket").setKeyName("dir"))
+            .setRecursive(false).setStartKey("").setNumEntries(10)
+            .setRequireFileSystemLayout(require)
+            .build();
+
+    for (OzoneManagerProtocolProtos.Type type : Arrays.asList(
+        OzoneManagerProtocolProtos.Type.ListStatus, 
OzoneManagerProtocolProtos.Type.ListStatusLight)) {
+      OzoneManagerProtocolProtos.OMRequest request = 
Mockito.mock(OzoneManagerProtocolProtos.OMRequest.class);
+      Mockito.when(request.getTraceID()).thenReturn("traceId");
+      Mockito.when(request.getCmdType()).thenReturn(type);
+      
Mockito.when(request.getListStatusRequest()).thenReturn(listStatusRequest);
+      requestHandler.handleReadRequest(request);
+    }
+
+    ArgumentCaptor<OmKeyArgs> listStatusArgs = 
ArgumentCaptor.forClass(OmKeyArgs.class);
+    Mockito.verify(ozoneManager).listStatus(listStatusArgs.capture(), 
Mockito.anyBoolean(), Mockito.anyString(),
+        Mockito.anyLong(), Mockito.anyBoolean());
+    Assertions.assertEquals(require, 
listStatusArgs.getValue().isRequireFileSystemLayout());
+    ArgumentCaptor<OmKeyArgs> listStatusLightArgs = 
ArgumentCaptor.forClass(OmKeyArgs.class);
+    
Mockito.verify(ozoneManager).listStatusLight(listStatusLightArgs.capture(), 
Mockito.anyBoolean(),
+        Mockito.anyString(), Mockito.anyLong(), Mockito.anyBoolean());
+    Assertions.assertEquals(require, 
listStatusLightArgs.getValue().isRequireFileSystemLayout());
+  }
+
   /**
    * Test to verify BasicOmKeyInfo encryption field works in listKeysLight.
    */
diff --git 
a/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/BasicRootedOzoneClientAdapterImpl.java
 
b/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/BasicRootedOzoneClientAdapterImpl.java
index 8540929de3b..311c7bfc3a3 100644
--- 
a/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/BasicRootedOzoneClientAdapterImpl.java
+++ 
b/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/BasicRootedOzoneClientAdapterImpl.java
@@ -89,6 +89,7 @@
 import org.apache.hadoop.ozone.client.io.OzoneDataStreamOutput;
 import org.apache.hadoop.ozone.client.io.OzoneOutputStream;
 import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
+import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions;
 import org.apache.hadoop.ozone.client.rpc.RpcClient;
 import org.apache.hadoop.ozone.container.common.helpers.BlockData;
 import org.apache.hadoop.ozone.om.exceptions.OMException;
@@ -976,21 +977,60 @@ public List<FileStatusAdapter> listStatus(String pathStr, 
boolean recursive,
     // Internally we need startKey to be passed into bucket.listStatus
     String startKey = ofsStartPath.getKeyName();
     try {
-      OzoneBucket bucket = getBucket(ofsPath, false);
       List<OzoneFileStatus> statuses = Collections.emptyList();
       List<OzoneFileStatusLight> lightStatuses = Collections.emptyList();
-      if (bucket.isSourcePathExist()) {
-        if (lite) {
-          lightStatuses = bucket.listStatusLight(keyName, recursive, startKey, 
numEntries);
-        } else {
-          statuses = bucket.listStatus(keyName, recursive, startKey, 
numEntries);
+      if 
(proxy.getOmVersion().compareTo(OzoneManagerVersion.LIST_STATUS_REJECTS_OBS) >= 
0) {
+        // Ask OM to reject an OBJECT_STORE bucket (HDDS-16760), so list 
without
+        // fetching the bucket first. The bucket is fetched only when the 
listing
+        // fails because a bucket is missing, to tell an orphan link bucket
+        // (empty listing) from a bucket that does not exist.
+        ListStatusLightOptions options = ListStatusLightOptions.builder()
+            .setVolumeName(ofsPath.getVolumeName())
+            .setBucketName(ofsPath.getBucketName())
+            .setKeyName(keyName)
+            .setRecursive(recursive)
+            .setStartKey(startKey)
+            .setNumEntries(numEntries)
+            .setRequireFileSystemLayout(true)
+            .build();
+        try {
+          if (lite) {
+            lightStatuses = proxy.listStatusLight(options);
+          } else {
+            statuses = proxy.listStatus(options);
+          }
+        } catch (OMException e) {
+          if (e.getResult() == 
OMException.ResultCodes.NOT_SUPPORTED_OPERATION) {
+            // Surface it as IllegalArgumentException, matching the client-side
+            // layout check of the fallback path below.
+            throw new IllegalArgumentException(e.getMessage());
+          }
+          if (e.getResult() != OMException.ResultCodes.BUCKET_NOT_FOUND
+              && e.getResult() != OMException.ResultCodes.VOLUME_NOT_FOUND) {
+            throw e;
+          }
+          // Rethrows as before if the bucket itself does not exist.
+          OzoneBucket bucket = getBucket(ofsPath, false);
+          if (bucket.isSourcePathExist()) {
+            throw e;
+          }
+          logOrphanLinkBucket(bucket);
         }
-
       } else {
-        LOG.warn("Source Bucket does not exist, link bucket {} is orphan " +
-            "and returning empty list of files inside it", bucket.getName());
+        // Older OM has no server-side OBJECT_STORE check; validate the bucket
+        // layout on the client, matching pre-HDDS-16760 behavior.
+        OzoneBucket bucket = getBucket(ofsPath, false);
+        if (bucket.isSourcePathExist()) {
+          if (lite) {
+            lightStatuses = bucket.listStatusLight(keyName, recursive, 
startKey, numEntries);
+          } else {
+            statuses = bucket.listStatus(keyName, recursive, startKey, 
numEntries);
+          }
+        } else {
+          logOrphanLinkBucket(bucket);
+        }
       }
-      
+
       // Note: result in statuses above doesn't have volume/bucket path since
       //  they are from the server.
       String ofsPathPrefix = ofsPath.getNonKeyPath();
@@ -1014,6 +1054,11 @@ public List<FileStatusAdapter> listStatus(String 
pathStr, boolean recursive,
     }
   }
 
+  private static void logOrphanLinkBucket(OzoneBucket bucket) {
+    LOG.warn("Source Bucket does not exist, link bucket {} is orphan " +
+        "and returning empty list of files inside it", bucket.getName());
+  }
+
   @Override
   public Token<OzoneTokenIdentifier> getDelegationToken(String renewer)
       throws IOException {
diff --git 
a/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterListStatus.java
 
b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterListStatus.java
new file mode 100644
index 00000000000..541a42ba9a9
--- /dev/null
+++ 
b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterListStatus.java
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hadoop.fs.ozone;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.OzoneManagerVersion;
+import org.apache.hadoop.ozone.client.ObjectStore;
+import org.apache.hadoop.ozone.client.OzoneBucket;
+import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
+import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InOrder;
+
+/**
+ * Unit tests for {@link BasicRootedOzoneClientAdapterImpl#listStatus} on a key
+ * path, covering the listing without InfoBucket added by HDDS-16760, the
+ * orphan link bucket handling on the error path, and the fallback used when
+ * the OM predates {@link OzoneManagerVersion#LIST_STATUS_REJECTS_OBS}. Uses a
+ * partial mock so no OM connection is required.
+ */
+public class TestBasicRootedOzoneClientAdapterListStatus {
+
+  private static final String DIR_PATH = "/vol/bucket/dir";
+  private static final URI OFS_URI = URI.create("ofs://om/");
+
+  private BasicRootedOzoneClientAdapterImpl adapter;
+  private ClientProtocol proxy;
+  private ObjectStore objectStore;
+  private OzoneBucket bucket;
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    adapter = mock(BasicRootedOzoneClientAdapterImpl.class, 
CALLS_REAL_METHODS);
+    proxy = mock(ClientProtocol.class);
+    objectStore = mock(ObjectStore.class);
+    bucket = mock(OzoneBucket.class);
+
+    when(proxy.getBucketDetails(anyString(), anyString())).thenReturn(bucket);
+    when(bucket.getVolumeName()).thenReturn("vol");
+    when(bucket.getName()).thenReturn("bucket");
+    
when(bucket.getBucketLayout()).thenReturn(BucketLayout.FILE_SYSTEM_OPTIMIZED);
+    // Mirror OzoneBucket: the source path exists unless link resolution 
clears it.
+    AtomicBoolean sourcePathExist = new AtomicBoolean(true);
+    doAnswer(invocation -> {
+      sourcePathExist.set(invocation.getArgument(0));
+      return null;
+    }).when(bucket).setSourcePathExist(anyBoolean());
+    when(bucket.isSourcePathExist()).thenAnswer(invocation -> 
sourcePathExist.get());
+
+    setField("proxy", proxy);
+    setField("objectStore", objectStore);
+    setField("config", new OzoneConfiguration());
+
+    // Default to an OM that performs the server-side OBJECT_STORE rejection.
+    
when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.LIST_STATUS_REJECTS_OBS);
+  }
+
+  private void setField(String name, Object value) throws Exception {
+    Field field = 
BasicRootedOzoneClientAdapterImpl.class.getDeclaredField(name);
+    field.setAccessible(true);
+    field.set(adapter, value);
+  }
+
+  private List<FileStatusAdapter> listStatus(boolean lite) throws IOException {
+    return adapter.listStatus(DIR_PATH, false, "", 100, OFS_URI, new 
Path("/"), "user", lite);
+  }
+
+  private void failListing(OMException e) throws IOException {
+    when(proxy.listStatus(any(ListStatusLightOptions.class))).thenThrow(e);
+    
when(proxy.listStatusLight(any(ListStatusLightOptions.class))).thenThrow(e);
+  }
+
+  /** The options the adapter sends: the listed path, asking OM to reject 
OBJECT_STORE. */
+  private static ListStatusLightOptions fsListing() {
+    return argThat(o -> o.getVolumeName().equals("vol") && 
o.getBucketName().equals("bucket")
+        && o.getKeyName().equals("dir") && !o.isRecursive() && 
o.getNumEntries() == 100
+        && o.isRequireFileSystemLayout());
+  }
+
+  /** Makes the bucket a link whose source volume has been deleted. */
+  private void makeOrphanLink() throws IOException {
+    when(bucket.isLink()).thenReturn(true);
+    when(bucket.getSourceVolume()).thenReturn("srcvol");
+    when(bucket.getSourceBucket()).thenReturn("srcbucket");
+    when(objectStore.getVolume("srcvol"))
+        .thenThrow(new OMException("no volume", ResultCodes.VOLUME_NOT_FOUND));
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void listingIsTheOnlyOmCall(boolean lite) throws IOException {
+    assertThat(listStatus(lite)).isEmpty();
+
+    if (lite) {
+      verify(proxy).listStatusLight(fsListing());
+    } else {
+      verify(proxy).listStatus(fsListing());
+    }
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+    verify(objectStore, never()).getVolume(anyString());
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = ResultCodes.class, names = {"BUCKET_NOT_FOUND", 
"VOLUME_NOT_FOUND"})
+  public void orphanLinkReturnsEmptyListAfterFailedListing(ResultCodes code) 
throws IOException {
+    // OM cannot resolve the link to its deleted source, so the listing fails
+    // and only then does the client fetch the bucket to recognise the orphan.
+    failListing(new OMException("source missing", code));
+    makeOrphanLink();
+
+    for (boolean lite : new boolean[] {true, false}) {
+      assertThat(listStatus(lite)).isEmpty();
+    }
+
+    InOrder order = inOrder(proxy);
+    order.verify(proxy).listStatusLight(fsListing());
+    order.verify(proxy).getBucketDetails("vol", "bucket");
+    order.verify(proxy).listStatus(fsListing());
+    order.verify(proxy).getBucketDetails("vol", "bucket");
+  }
+
+  @Test
+  public void orphanLinkDeclaredObjectStoreIsRejected() throws IOException {
+    // The fallback validates the link's own layout for an orphan; the error
+    // path must keep doing so.
+    failListing(new OMException("source missing", 
ResultCodes.BUCKET_NOT_FOUND));
+    makeOrphanLink();
+    when(bucket.getBucketLayout()).thenReturn(BucketLayout.OBJECT_STORE);
+
+    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, 
() -> listStatus(false));
+    assertThat(ex.getMessage()).contains("OBJECT_STORE");
+  }
+
+  @Test
+  public void missingBucketPropagatesFromBucketFetch() throws IOException {
+    failListing(new OMException("no bucket in listing", 
ResultCodes.BUCKET_NOT_FOUND));
+    OMException missing = new OMException("no bucket", 
ResultCodes.BUCKET_NOT_FOUND);
+    when(proxy.getBucketDetails("vol", "bucket")).thenThrow(missing);
+
+    OMException ex = assertThrows(OMException.class, () -> listStatus(false));
+    assertSame(missing, ex);
+  }
+
+  @Test
+  public void existingBucketOnErrorPathRethrowsListingFailure() throws 
IOException {
+    // The bucket exists by the time it is fetched, so it is not an orphan 
link.
+    OMException listingFailure = new OMException("no bucket", 
ResultCodes.BUCKET_NOT_FOUND);
+    failListing(listingFailure);
+
+    OMException ex = assertThrows(OMException.class, () -> listStatus(false));
+    assertSame(listingFailure, ex);
+  }
+
+  @Test
+  public void obsRejectionMappedToIllegalArgumentException() throws 
IOException {
+    failListing(new OMException("Bucket: bucket has layout: OBJECT_STORE", 
ResultCodes.NOT_SUPPORTED_OPERATION));
+
+    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, 
() -> listStatus(false));
+    assertThat(ex.getMessage()).contains("OBJECT_STORE");
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @Test
+  public void fileNotFoundMappedToFileNotFoundException() throws IOException {
+    failListing(new OMException("no dir", ResultCodes.FILE_NOT_FOUND));
+
+    assertThrows(FileNotFoundException.class, () -> listStatus(false));
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @Test
+  public void otherOMExceptionPropagates() throws IOException {
+    failListing(new OMException("denied", ResultCodes.PERMISSION_DENIED));
+
+    OMException ex = assertThrows(OMException.class, () -> listStatus(false));
+    assertEquals(ResultCodes.PERMISSION_DENIED, ex.getResult());
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @Test
+  public void olderOmFallsBackToClientSideBucketCheck() throws IOException {
+    // An OM older than LIST_STATUS_REJECTS_OBS has no server-side OBJECT_STORE
+    // rejection, so the adapter must fetch the bucket and validate its layout.
+    
when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.LOOKUP_FILE_REJECTS_OBS);
+
+    assertThat(listStatus(false)).isEmpty();
+
+    verify(proxy).getBucketDetails("vol", "bucket");
+    verify(bucket).listStatus("dir", false, "", 100);
+    verify(proxy, never()).listStatus(any(ListStatusLightOptions.class));
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void zduOmFallsBackToClientSideBucketCheck(boolean lite) throws 
IOException {
+    // An OM finalized for ZDU reports ZDU even without this change, and such 
an
+    // OM ignores requireFileSystemLayout, so the client must still fetch the
+    // bucket and validate its layout.
+    when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.ZDU);
+
+    assertThat(listStatus(lite)).isEmpty();
+
+    verify(proxy).getBucketDetails("vol", "bucket");
+    if (lite) {
+      verify(bucket).listStatusLight("dir", false, "", 100);
+    } else {
+      verify(bucket).listStatus("dir", false, "", 100);
+    }
+    verify(proxy, never()).listStatus(any(ListStatusLightOptions.class));
+    verify(proxy, never()).listStatusLight(any(ListStatusLightOptions.class));
+  }
+
+  @Test
+  public void olderOmRejectsObsBucketClientSide() throws IOException {
+    
when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.LOOKUP_FILE_REJECTS_OBS);
+    when(bucket.getBucketLayout()).thenReturn(BucketLayout.OBJECT_STORE);
+
+    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, 
() -> listStatus(false));
+    assertThat(ex.getMessage()).contains("OBJECT_STORE");
+    verify(bucket, never()).listStatus(anyString(), anyBoolean(), anyString(), 
anyLong());
+  }
+
+  @Test
+  public void olderOmOrphanLinkReturnsEmptyList() throws IOException {
+    
when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.LOOKUP_FILE_REJECTS_OBS);
+    makeOrphanLink();
+
+    assertThat(listStatus(false)).isEmpty();
+    verify(bucket, never()).listStatus(anyString(), anyBoolean(), anyString(), 
anyLong());
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
index 8487bdb4aa1..391785a0861 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
@@ -709,6 +709,11 @@ public List<OzoneFileStatusLight> 
listStatusLight(ListStatusLightOptions options
     return null;
   }
 
+  @Override
+  public List<OzoneFileStatus> listStatus(ListStatusLightOptions options) 
throws IOException {
+    return null;
+  }
+
   @Override
   public boolean addAcl(OzoneObj obj, OzoneAcl acl) throws IOException {
     return false;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to