You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ozone.apache.org by we...@apache.org on 2026/10/19 22:24:37 UTC

(ozone) branch master updated: HDDS-16948. Remove the per-call InfoBucket from rooted-ofs open (#11613)

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 bb296c9daa4 HDDS-16948. Remove the per-call InfoBucket from rooted-ofs open (#11613)
bb296c9daa4 is described below

commit bb296c9daa47f627c51d9899c8e29acfc7272412
Author: Andrey Yarovoy <11...@users.noreply.github.com>
AuthorDate: Mon Oct 19 18:24:30 2026 -0400

    HDDS-16948. Remove the per-call InfoBucket from rooted-ofs open (#11613)
---
 .../apache/hadoop/ozone/OzoneManagerVersion.java   |   5 +
 .../ozone/client/protocol/ClientProtocol.java      |  11 +
 .../apache/hadoop/ozone/client/rpc/RpcClient.java  |  26 +-
 .../apache/hadoop/ozone/om/helpers/OmKeyArgs.java  |   4 +-
 ...OzoneManagerProtocolClientSideTranslatorPB.java |   3 +
 .../ozone/AbstractRootedOzoneFileSystemTest.java   |  39 +++
 .../hadoop/fs/ozone/TestOfsOpenRpcBenchmark.java   | 313 +++++++++++++++++++++
 .../src/main/proto/OmClientProtocol.proto          |   3 +
 .../apache/hadoop/ozone/om/OmMetadataReader.java   |   8 +-
 .../protocolPB/OzoneManagerRequestHandler.java     |   1 +
 .../hadoop/ozone/om/TestOMMetadataReader.java      |  74 +++++
 .../protocolPB/TestOzoneManagerRequestHandler.java |  31 ++
 .../ozone/BasicRootedOzoneClientAdapterImpl.java   |  17 ++
 .../TestBasicRootedOzoneClientAdapterReadFile.java | 164 +++++++++++
 .../hadoop/ozone/client/ClientProtocolStub.java    |   6 +
 15 files changed, 699 insertions(+), 6 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 7c74864d027..5507a237f9a 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
@@ -80,6 +80,11 @@ public enum OzoneManagerVersion implements ComponentVersion {
           + "server-side when the request sets requireFileSystemLayout, so file "
           + "system clients no longer need the client-side InfoBucket layout "
           + "check for listStatus"),
+  GET_KEY_INFO_REJECTS_OBS(102,
+      "OzoneManager version that rejects getKeyInfo 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 open"),
 
   // Only operations included in the first release with version 102 may share it.
   // S3 volume context support added later needs a new 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 f2b89b4d74f..58fa263e35c 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
@@ -1163,6 +1163,17 @@ void createDirectory(String volumeName, String bucketName, String keyName)
   OzoneInputStream readFile(String volumeName, String bucketName,
       String keyName) throws IOException;
 
+  /**
+   * Creates an input stream for reading file contents, like
+   * {@link #readFile(String, String, String)}.
+   *
+   * @param requireFileSystemLayout if true, OM rejects the read with
+   *     NOT_SUPPORTED_OPERATION if the resolved bucket layout is OBJECT_STORE.
+   *     An OM older than OzoneManagerVersion.GET_KEY_INFO_REJECTS_OBS ignores it.
+   */
+  OzoneInputStream readFile(String volumeName, String bucketName,
+      String keyName, boolean requireFileSystemLayout) throws IOException;
+
   /**
    * Creates an output stream for writing to a file.
    *
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 71650110650..22b74226072 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
@@ -2051,6 +2051,11 @@ private OmKeyInfo getS3PartKeyInfo(
   public OmKeyInfo getKeyInfo(
       String volumeName, String bucketName, String keyName,
       boolean forceUpdateContainerCache) throws IOException {
+    return getKeyInfo(volumeName, bucketName, keyName, forceUpdateContainerCache, false);
+  }
+
+  private OmKeyInfo getKeyInfo(String volumeName, String bucketName, String keyName,
+      boolean forceUpdateContainerCache, boolean requireFileSystemLayout) throws IOException {
     Objects.requireNonNull(volumeName, "volumeName == null");
     Objects.requireNonNull(bucketName, "bucketName == null");
     Objects.requireNonNull(keyName, "keyName == null");
@@ -2061,6 +2066,7 @@ public OmKeyInfo getKeyInfo(
         .setSortDatanodesInPipeline(topologyAwareReadEnabled)
         .setLatestVersionLocation(getLatestVersionLocation)
         .setForceUpdateContainerCacheFromSCM(forceUpdateContainerCache)
+        .setRequireFileSystemLayout(requireFileSystemLayout)
         .build();
     return getKeyInfo(keyArgs);
   }
@@ -2464,12 +2470,19 @@ public void createDirectory(String volumeName, String bucketName,
   @Override
   public OzoneInputStream readFile(String volumeName, String bucketName,
       String keyName) throws IOException {
+    return readFile(volumeName, bucketName, keyName, false);
+  }
+
+  @Override
+  public OzoneInputStream readFile(String volumeName, String bucketName,
+      String keyName, boolean requireFileSystemLayout) throws IOException {
     OmKeyArgs keyArgs = new OmKeyArgs.Builder()
         .setVolumeName(volumeName)
         .setBucketName(bucketName)
         .setKeyName(keyName)
         .setSortDatanodesInPipeline(topologyAwareReadEnabled)
         .setLatestVersionLocation(getLatestVersionLocation)
+        .setRequireFileSystemLayout(requireFileSystemLayout)
         .build();
     final OmKeyInfo keyInfo;
     if (OzoneManagerVersion.OPTIMIZED_GET_KEY_INFO.isSupportedBy(omVersion)) {
@@ -2482,7 +2495,7 @@ public OzoneInputStream readFile(String volumeName, String bucketName,
     } else {
       keyInfo = ozoneManagerClient.lookupFile(keyArgs);
     }
-    return getInputStreamWithRetryFunction(keyInfo);
+    return getInputStreamWithRetryFunction(keyInfo, requireFileSystemLayout);
   }
 
   @Override
@@ -2505,10 +2518,19 @@ public OzoneOutputStream createFile(String volumeName, String bucketName,
    */
   private OzoneInputStream getInputStreamWithRetryFunction(
       OmKeyInfo keyInfo) throws IOException {
+    return getInputStreamWithRetryFunction(keyInfo, false);
+  }
+
+  /**
+   * Like {@link #getInputStreamWithRetryFunction(OmKeyInfo)}; the retry lookup
+   * also asks OM to reject an OBJECT_STORE bucket if requireFileSystemLayout.
+   */
+  private OzoneInputStream getInputStreamWithRetryFunction(
+      OmKeyInfo keyInfo, boolean requireFileSystemLayout) throws IOException {
     return createInputStream(keyInfo, omKeyInfo -> {
       try {
         return getKeyInfo(omKeyInfo.getVolumeName(), omKeyInfo.getBucketName(),
-            omKeyInfo.getKeyName(), true);
+            omKeyInfo.getKeyName(), true, requireFileSystemLayout);
       } catch (IOException e) {
         LOG.error("Unable to lookup key {} on retry.", keyInfo.getKeyName(), e);
         return null;
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 2280845cd3b..9a6ade36350 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,8 +64,8 @@ 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.
+  // Set by file system clients so that OM rejects a listing or a key info
+  // read on an OBJECT_STORE bucket.
   private final boolean requireFileSystemLayout;
   private final String expectedETag;
   private final boolean derivedKeyPiggyBacking;
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 e53d2dad73b..5d5e20d0a34 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
@@ -944,6 +944,9 @@ public KeyInfoWithVolumeContext getKeyInfo(OmKeyArgs args,
     GetKeyInfoRequest.Builder req = GetKeyInfoRequest.newBuilder();
     req.setKeyArgs(args.toProtobuf());
     req.setAssumeS3Context(assumeS3Context);
+    if (args.isRequireFileSystemLayout()) {
+      req.setRequireFileSystemLayout(true);
+    }
 
     OMRequest omRequest = createOMRequest(Type.GetKeyInfo)
         .setGetKeyInfoRequest(req)
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 99234920214..d1edd78db6a 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
@@ -1990,6 +1990,45 @@ void testListStatusOrphanLinkBucketIsEmpty() throws Exception {
     assertEquals(BUCKET_NOT_FOUND, exception.getResult());
   }
 
+  @Test
+  void testOpenDoesNotTriggerInfoBucket() throws Exception {
+    Path file = new Path(bucketPath, "open-rpc-" + RandomStringUtils.secure().nextAlphanumeric(5));
+    ContractTestUtils.writeTextFile(fs, file, "data", true);
+
+    OMMetrics metrics = getOMMetrics();
+    long bucketInfosBefore = metrics.getNumBucketInfos();
+
+    try (FSDataInputStream stream = fs.open(file)) {
+      assertEquals('d', stream.read());
+    }
+
+    assertEquals(bucketInfosBefore, metrics.getNumBucketInfos(), "open must not trigger InfoBucket");
+  }
+
+  @Test
+  void testOpenRejectsObsBucket() throws Exception {
+    OzoneBucket obsBucket = DataTestUtil.createVolumeAndBucket(client, BucketLayout.OBJECT_STORE);
+    DataTestUtil.createKey(obsBucket, "obs-key", "data".getBytes(StandardCharsets.UTF_8));
+    Path obsKeyPath = new Path(new Path(new Path(OZONE_URI_DELIMITER, obsBucket.getVolumeName()),
+        obsBucket.getName()), "obs-key");
+
+    OMMetrics metrics = getOMMetrics();
+    long bucketInfosBefore = metrics.getNumBucketInfos();
+
+    IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> fs.open(obsKeyPath));
+    assertThat(exception.getMessage()).contains(obsBucket.getName());
+    assertThat(exception.getMessage()).contains("OBJECT_STORE");
+    assertEquals(bucketInfosBefore, metrics.getNumBucketInfos(), "open must not trigger InfoBucket");
+  }
+
+  @Test
+  void testOpenInMissingBucketFails() throws Exception {
+    // The failure now comes from GetKeyInfo instead of InfoBucket, with the same result code.
+    Path missingFile = new Path(new Path(new Path(OZONE_URI_DELIMITER, volumeName), "missing-bucket"), "file");
+    OMException exception = assertThrows(OMException.class, () -> fs.open(missingFile));
+    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/TestOfsOpenRpcBenchmark.java b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOfsOpenRpcBenchmark.java
new file mode 100644
index 00000000000..81fcb9a3176
--- /dev/null
+++ b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOfsOpenRpcBenchmark.java
@@ -0,0 +1,313 @@
+/*
+ * 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.apache.ozone.test.MetricsAsserts.getLongCounter;
+import static org.apache.ozone.test.MetricsAsserts.getMetrics;
+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 open OM RPC count (HDDS-16948).
+ *
+ * <p>The same workload runs unchanged on the baseline ({@code master}: InfoBucket + GetKeyInfo per open) and on the
+ * fix (a single GetKeyInfo that asks OM to reject an OBJECT_STORE bucket). It follows
+ * {@link TestOfsListStatusRpcBenchmark}: a file in each of {@link #NUM_BUCKETS} buckets is opened
+ * {@link #ACCESSES_PER_BUCKET} times from a shuffled access sequence, single-threaded and with
+ * {@link #CONCURRENT_THREADS} client threads. Each call opens and closes the file without reading it, so datanode
+ * reads do not mask the OM saving.
+ */
+@Tag("benchmark")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestOfsOpenRpcBenchmark {
+
+  private static final Logger LOG = LoggerFactory.getLogger(TestOfsOpenRpcBenchmark.class);
+
+  /** Distinct buckets in the working set. */
+  private static final int NUM_BUCKETS = 200;
+  /** open 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 open in the concurrent comparison. */
+  private static final int CONCURRENT_THREADS = 10;
+  /** Size of each opened file. */
+  private static final int FILE_SIZE = 1024;
+
+  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();
+    byte[] data = ContractTestUtils.dataset(FILE_SIZE, 'a', 26);
+    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 file = new Path("/" + volName + "/" + buckName + "/file");
+        ContractTestUtils.createFile(setupFs, file, true, data);
+        for (int a = 0; a < ACCESSES_PER_BUCKET; a++) {
+          accessSequence.add(file);
+        }
+      }
+      assertThat(setupFs.getFileStatus(accessSequence.get(0)).getLen()).isEqualTo(FILE_SIZE);
+    }
+    Collections.shuffle(accessSequence, new Random(SHUFFLE_SEED));
+  }
+
+  @AfterAll
+  void shutdown() {
+    IOUtils.closeQuietly(client);
+    if (cluster != null) {
+      cluster.shutdown();
+    }
+  }
+
+  @Test
+  void benchmarkOpen() 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 of the workload: every open issues one GetKeyInfo, and the bucket and volume RPCs stay within one per
+   * open.
+   */
+  private void assertWorkloadInvariants(Result r) {
+    assertThat(r.getKeyInfoRpcs).isEqualTo(TOTAL_CALLS);
+    assertThat(r.bucketInfoRpcs).isBetween(0L, (long) TOTAL_CALLS);
+    assertThat(r.volumeInfoRpcs).isBetween(0L, (long) TOTAL_CALLS);
+  }
+
+  private void logResult(String label, Result r) {
+    long omRpcs = r.volumeInfoRpcs + r.bucketInfoRpcs + r.getKeyInfoRpcs;
+    LOG.info(String.format("%n"
+            + "BENCHRESULT OFS open OM RPC benchmark (HDDS-16948) - %s%n"
+            + "  buckets=%d, accesses/bucket=%d, total open=%d, threads=%d, file size=%d%n"
+            + "  ------------------------------------------------------------%n"
+            + "  InfoVolume RPCs             : %d%n"
+            + "  InfoBucket RPCs             : %d%n"
+            + "  GetKeyInfo 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 open/s%n",
+        label, NUM_BUCKETS, ACCESSES_PER_BUCKET, TOTAL_CALLS, r.threads, FILE_SIZE,
+        r.volumeInfoRpcs, r.bucketInfoRpcs, r.getKeyInfoRpcs,
+        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);
+  }
+
+  /** OMMetrics has no getter for this counter, so read it from the metrics source, as on the baseline. */
+  private static long getKeyInfoCount() {
+    return getLongCounter("NumGetKeyInfo", getMetrics("OMMetrics"));
+  }
+
+  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 getKeyInfoBefore = getKeyInfoCount();
+      long elapsedNanos = threads == 1
+          ? runSequential(fs, latencyNanos)
+          : runConcurrent(fs, threads, latencyNanos);
+      return new Result(threads,
+          metrics.getNumVolumeInfos() - volumeInfosBefore,
+          metrics.getNumBucketInfos() - bucketInfosBefore,
+          getKeyInfoCount() - getKeyInfoBefore,
+          elapsedNanos, latencyNanos);
+    }
+  }
+
+  private static void openAndClose(FileSystem fs, Path path) throws IOException {
+    fs.open(path).close();
+  }
+
+  private long runSequential(FileSystem fs, long[] latencyNanos) throws IOException {
+    long startNanos = System.nanoTime();
+    int i = 0;
+    for (Path path : accessSequence) {
+      long callStart = System.nanoTime();
+      openAndClose(fs, 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();
+            openAndClose(fs, 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 getKeyInfoRpcs;
+    private final long elapsedNanos;
+    private final long[] sortedLatencyNanos;
+
+    Result(int threads, long volumeInfoRpcs, long bucketInfoRpcs, long getKeyInfoRpcs, long elapsedNanos,
+        long[] latencyNanos) {
+      this.threads = threads;
+      this.volumeInfoRpcs = volumeInfoRpcs;
+      this.bucketInfoRpcs = bucketInfoRpcs;
+      this.getKeyInfoRpcs = getKeyInfoRpcs;
+      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 bff28097b4b..efdae1cbc4e 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -1430,6 +1430,9 @@ message LookupKeyResponse {
 message GetKeyInfoRequest {
   required KeyArgs keyArgs = 1;
   optional bool assumeS3Context = 2;
+  // Set by file system clients: OM rejects the request with
+  // NOT_SUPPORTED_OPERATION if the resolved bucket layout is OBJECT_STORE.
+  optional bool requireFileSystemLayout = 3;
 }
 
 message GetKeyInfoResponse {
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 240591cd3b1..9c7f913bda0 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
@@ -197,6 +197,9 @@ public KeyInfoWithVolumeContext getKeyInfo(final OmKeyArgs args,
         normalizeKeyArgs(bucket.update(resolvedVolumeArgs), bucket);
 
     try {
+      if (resolvedArgs.isRequireFileSystemLayout()) {
+        validateFileSystemLayout(bucket);
+      }
       if (isAclEnabled) {
         captureLatencyNs(perfMetrics.getGetKeyInfoAclCheckLatencyNs(), () ->
             checkAcls(ResourceType.KEY,
@@ -320,8 +323,9 @@ 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).
+   * listStatus, listStatusLight and getKeyInfo call it only when the client
+   * asked for it, because they also serve object store callers (S3 shallow key
+   * listing, S3 GetObject and HeadObject, readKey).
    */
   private static void validateFileSystemLayout(ResolvedBucket bucket) throws OMException {
     if (bucket.bucketLayout() != null) {
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 495e5dd9aed..5d6be583ab2 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
@@ -727,6 +727,7 @@ private GetKeyInfoResponse getKeyInfo(GetKeyInfoRequest request,
         .setForceUpdateContainerCacheFromSCM(
             keyArgs.getForceUpdateContainerCacheFromSCM())
         .setMultipartUploadPartNumber(keyArgs.getMultipartNumber())
+        .setRequireFileSystemLayout(request.getRequireFileSystemLayout())
         .build();
     KeyInfoWithVolumeContext keyInfo = impl.getKeyInfo(omKeyArgs,
         request.getAssumeS3Context());
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 4343480376f..37f8c79a910 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
@@ -24,6 +24,7 @@
 import static org.apache.hadoop.ozone.security.acl.OzoneObj.ResourceType.VOLUME;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -56,7 +57,9 @@
 import org.apache.hadoop.ozone.om.helpers.ListKeysResult;
 import org.apache.hadoop.ozone.om.helpers.OmKeyArgs;
 import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs;
 import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus;
+import org.apache.hadoop.ozone.om.helpers.S3VolumeContext;
 import org.apache.hadoop.ozone.om.protocolPB.grpc.GrpcClientConstants;
 import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication;
 import org.apache.hadoop.ozone.security.STSTokenIdentifier;
@@ -68,6 +71,9 @@
 import org.apache.hadoop.security.UserGroupInformation;
 import org.junit.jupiter.api.AfterEach;
 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.ArgumentCaptor;
 import org.mockito.MockedStatic;
 import org.slf4j.Logger;
@@ -971,4 +977,72 @@ public void listStatusServesObjectStoreLayoutWhenNotRequested() throws Exception
     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));
   }
+
+  private static OmMetadataReader newGetKeyInfoReader(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));
+    OMPerformanceMetrics perfMetrics = mock(OMPerformanceMetrics.class);
+    when(perfMetrics.getGetKeyInfoResolveBucketLatencyNs()).thenReturn(mock(ConcurrentMutableRate.class));
+    when(ozoneManager.getPerfMetrics()).thenReturn(perfMetrics);
+    when(ozoneManager.resolveBucketLink(any(OmKeyArgs.class)))
+        .thenReturn(new ResolvedBucket("vol", bucketName, "vol", bucketName, "owner", layout));
+    when(ozoneManager.getS3VolumeContext(true)).thenReturn(new S3VolumeContext(
+        OmVolumeArgs.newBuilder().setVolume("vol").setAdminName("admin").setOwnerName("owner").build(), "user"));
+    when(keyManager.getKeyInfo(any(OmKeyArgs.class), any(ResolvedBucket.class), any()))
+        .thenReturn(mock(OmKeyInfo.class));
+    return new OmMetadataReader(keyManager, mock(PrefixManager.class), ozoneManager, mock(org.slf4j.Logger.class),
+        mock(AuditLogger.class), mock(OmMetadataReaderMetrics.class), null);
+  }
+
+  private static OmKeyArgs getKeyInfoArgs(String bucketName, boolean requireFileSystemLayout) {
+    return new OmKeyArgs.Builder()
+        .setVolumeName("vol")
+        .setBucketName(bucketName)
+        .setKeyName("file")
+        .setRequireFileSystemLayout(requireFileSystemLayout)
+        .build();
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void getKeyInfoRejectsObjectStoreLayout(boolean assumeS3Context) throws Exception {
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newGetKeyInfoReader(keyManager, "obs-bucket", BucketLayout.OBJECT_STORE);
+
+    OMException exception = assertThrows(OMException.class,
+        () -> reader.getKeyInfo(getKeyInfoArgs("obs-bucket", true), assumeS3Context));
+    assertEquals(ResultCodes.NOT_SUPPORTED_OPERATION, exception.getResult());
+    assertTrue(exception.getMessage().contains("obs-bucket"));
+    assertTrue(exception.getMessage().contains("OBJECT_STORE"));
+
+    verify(keyManager, never()).getKeyInfo(any(), any(), any());
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = BucketLayout.class, names = {"FILE_SYSTEM_OPTIMIZED", "LEGACY"})
+  public void getKeyInfoAllowsFileSystemLayouts(BucketLayout layout) throws Exception {
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newGetKeyInfoReader(keyManager, "fs-bucket", layout);
+
+    assertNotNull(reader.getKeyInfo(getKeyInfoArgs("fs-bucket", true), false).getKeyInfo());
+
+    verify(keyManager).getKeyInfo(any(OmKeyArgs.class), any(ResolvedBucket.class), any());
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void getKeyInfoServesObjectStoreLayoutWhenNotRequested(boolean assumeS3Context) throws Exception {
+    // Object store callers, such as S3 GetObject / HeadObject and readKey, read
+    // OBJECT_STORE buckets without asking for a file system layout.
+    KeyManager keyManager = mock(KeyManager.class);
+    OmMetadataReader reader = newGetKeyInfoReader(keyManager, "obs-bucket", BucketLayout.OBJECT_STORE);
+
+    assertNotNull(reader.getKeyInfo(getKeyInfoArgs("obs-bucket", false), assumeS3Context).getKeyInfo());
+
+    verify(keyManager).getKeyInfo(any(OmKeyArgs.class), any(ResolvedBucket.class), any());
+  }
 }
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 db49e81906c..e061296d5b0 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
@@ -42,6 +42,7 @@
 import org.apache.hadoop.ozone.om.exceptions.OMException;
 import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
 import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.KeyInfoWithVolumeContext;
 import org.apache.hadoop.ozone.om.helpers.ListKeysLightResult;
 import org.apache.hadoop.ozone.om.helpers.ListKeysResult;
 import org.apache.hadoop.ozone.om.helpers.OmKeyArgs;
@@ -404,6 +405,36 @@ public void testListStatusForwardsRequireFileSystemLayout(boolean require) throw
     Assertions.assertEquals(require, listStatusLightArgs.getValue().isRequireFileSystemLayout());
   }
 
+  /**
+   * GetKeyInfo must forward requireFileSystemLayout from the request into the
+   * OmKeyArgs handed to the OM.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testGetKeyInfoForwardsRequireFileSystemLayout(boolean require) throws IOException {
+    OzoneManagerRequestHandler requestHandler = getRequestHandler(10);
+    OzoneManager ozoneManager = requestHandler.getOzoneManager();
+    KeyInfoWithVolumeContext keyInfo = KeyInfoWithVolumeContext.newBuilder().setKeyInfo(getMockedOmKeyInfo()).build();
+    Mockito.when(ozoneManager.getKeyInfo(any(OmKeyArgs.class), Mockito.anyBoolean())).thenReturn(keyInfo);
+    OzoneManagerProtocolProtos.GetKeyInfoRequest getKeyInfoRequest =
+        OzoneManagerProtocolProtos.GetKeyInfoRequest.newBuilder()
+            .setKeyArgs(OzoneManagerProtocolProtos.KeyArgs.newBuilder()
+                .setVolumeName("volume").setBucketName("bucket").setKeyName("key"))
+            .setRequireFileSystemLayout(require)
+            .build();
+    OzoneManagerProtocolProtos.OMRequest request = Mockito.mock(OzoneManagerProtocolProtos.OMRequest.class);
+    Mockito.when(request.getTraceID()).thenReturn("traceId");
+    Mockito.when(request.getCmdType()).thenReturn(OzoneManagerProtocolProtos.Type.GetKeyInfo);
+    Mockito.when(request.getGetKeyInfoRequest()).thenReturn(getKeyInfoRequest);
+
+    OzoneManagerProtocolProtos.OMResponse response = requestHandler.handleReadRequest(request);
+    Assertions.assertEquals(OzoneManagerProtocolProtos.Status.OK, response.getStatus());
+
+    ArgumentCaptor<OmKeyArgs> keyArgs = ArgumentCaptor.forClass(OmKeyArgs.class);
+    Mockito.verify(ozoneManager).getKeyInfo(keyArgs.capture(), Mockito.eq(false));
+    Assertions.assertEquals(require, keyArgs.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 cb867af7299..7edd69c3c8e 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
@@ -390,6 +390,23 @@ public InputStream readFile(String pathStr) throws IOException {
         config);
     String key = ofsPath.getKeyName();
     try {
+      // An empty bucket name takes the getBucket path, which reports it as FileNotFoundException.
+      if (!ofsPath.getBucketName().isEmpty()
+          && proxy.getOmVersion().compareTo(OzoneManagerVersion.GET_KEY_INFO_REJECTS_OBS) >= 0) {
+        // Ask OM to reject an OBJECT_STORE bucket (HDDS-16948), so read without fetching the bucket first.
+        try {
+          return proxy.readFile(ofsPath.getVolumeName(), ofsPath.getBucketName(), key, true).getInputStream();
+        } 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());
+          }
+          throw e;
+        }
+      }
+      // Older OM has no server-side OBJECT_STORE check; validate the bucket
+      // layout on the client, matching pre-HDDS-16948 behavior.
       OzoneBucket bucket = getBucket(ofsPath, false);
       return bucket.readFile(key).getInputStream();
     } catch (OMException ex) {
diff --git a/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterReadFile.java b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterReadFile.java
new file mode 100644
index 00000000000..7d0706466b4
--- /dev/null
+++ b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestBasicRootedOzoneClientAdapterReadFile.java
@@ -0,0 +1,164 @@
+/*
+ * 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.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+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.io.InputStream;
+import java.lang.reflect.Field;
+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.io.OzoneInputStream;
+import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
+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;
+
+/**
+ * Unit tests for {@link BasicRootedOzoneClientAdapterImpl#readFile}, covering the read without InfoBucket added by
+ * HDDS-16948 and the fallback used when the OM predates {@link OzoneManagerVersion#GET_KEY_INFO_REJECTS_OBS}.
+ * Uses a partial mock so no OM connection is required.
+ */
+public class TestBasicRootedOzoneClientAdapterReadFile {
+
+  private static final String FILE_PATH = "/vol/bucket/dir/file";
+
+  private BasicRootedOzoneClientAdapterImpl adapter;
+  private ClientProtocol proxy;
+  private OzoneBucket bucket;
+  private InputStream stream;
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    adapter = mock(BasicRootedOzoneClientAdapterImpl.class, CALLS_REAL_METHODS);
+    proxy = mock(ClientProtocol.class);
+    bucket = mock(OzoneBucket.class);
+    stream = mock(InputStream.class);
+    OzoneInputStream ozoneStream = new OzoneInputStream(stream);
+
+    when(proxy.readFile("vol", "bucket", "dir/file", true)).thenReturn(ozoneStream);
+    when(proxy.getBucketDetails("vol", "bucket")).thenReturn(bucket);
+    when(bucket.getName()).thenReturn("bucket");
+    when(bucket.getBucketLayout()).thenReturn(BucketLayout.FILE_SYSTEM_OPTIMIZED);
+    when(bucket.readFile("dir/file")).thenReturn(ozoneStream);
+
+    setField("proxy", proxy);
+    setField("objectStore", mock(ObjectStore.class));
+    setField("config", new OzoneConfiguration());
+
+    // Default to an OM that performs the server-side OBJECT_STORE rejection.
+    when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.GET_KEY_INFO_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 void failRead(OMException e) throws IOException {
+    when(proxy.readFile("vol", "bucket", "dir/file", true)).thenThrow(e);
+  }
+
+  @Test
+  public void readIsTheOnlyOmCall() throws IOException {
+    assertSame(stream, adapter.readFile(FILE_PATH));
+
+    verify(proxy).readFile("vol", "bucket", "dir/file", true);
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @Test
+  public void objectStoreRejectionSurfacesAsIllegalArgumentException() throws IOException {
+    String message = "Bucket: bucket has layout: OBJECT_STORE, which does not support file system semantics.";
+    failRead(new OMException(message, ResultCodes.NOT_SUPPORTED_OPERATION));
+
+    IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> adapter.readFile(FILE_PATH));
+    assertEquals(message, e.getMessage());
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = ResultCodes.class, names = {"KEY_NOT_FOUND", "FILE_NOT_FOUND", "NOT_A_FILE"})
+  public void missingFileSurfacesAsFileNotFoundException(ResultCodes code) throws IOException {
+    failRead(new OMException("missing", code));
+
+    FileNotFoundException e = assertThrows(FileNotFoundException.class, () -> adapter.readFile(FILE_PATH));
+    assertThat(e.getMessage()).startsWith(code.name());
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = ResultCodes.class, names = {"BUCKET_NOT_FOUND", "VOLUME_NOT_FOUND", "PERMISSION_DENIED"})
+  public void otherErrorsAreRethrownWithoutFetchingTheBucket(ResultCodes code) throws IOException {
+    // A missing bucket or volume fails as before, with the same result code that InfoBucket reported.
+    failRead(new OMException("failed", code));
+
+    OMException e = assertThrows(OMException.class, () -> adapter.readFile(FILE_PATH));
+    assertEquals(code, e.getResult());
+    verify(proxy, never()).getBucketDetails(anyString(), anyString());
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = OzoneManagerVersion.class, names = {"LOOKUP_FILE_REJECTS_OBS", "ZDU", "LIST_STATUS_REJECTS_OBS"})
+  public void olderOmFallsBackToClientSideBucketCheck(OzoneManagerVersion version) throws IOException {
+    // An OM finalized for ZDU reports ZDU, or a later version, even without this change, so only an OM at or above
+    // GET_KEY_INFO_REJECTS_OBS is trusted to reject OBJECT_STORE.
+    when(proxy.getOmVersion()).thenReturn(version);
+
+    assertSame(stream, adapter.readFile(FILE_PATH));
+
+    verify(proxy).getBucketDetails("vol", "bucket");
+    verify(bucket).readFile("dir/file");
+    verify(proxy, never()).readFile(anyString(), anyString(), anyString(), anyBoolean());
+  }
+
+  @Test
+  public void olderOmRejectsObsBucketClientSide() throws IOException {
+    when(proxy.getOmVersion()).thenReturn(OzoneManagerVersion.LIST_STATUS_REJECTS_OBS);
+    when(bucket.getBucketLayout()).thenReturn(BucketLayout.OBJECT_STORE);
+
+    IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> adapter.readFile(FILE_PATH));
+    assertThat(e.getMessage()).contains("Bucket: bucket has layout: OBJECT_STORE");
+    verify(bucket, never()).readFile(anyString());
+  }
+
+  @Test
+  public void pathWithoutBucketIsFileNotFound() throws IOException {
+    assertThrows(FileNotFoundException.class, () -> adapter.readFile("/vol"));
+
+    verify(proxy, never()).readFile(anyString(), anyString(), anyString(), anyBoolean());
+  }
+}
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 7d2ce8efa6a..6f5956d5a56 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
@@ -673,6 +673,12 @@ public OzoneInputStream readFile(String volumeName, String bucketName,
     return null;
   }
 
+  @Override
+  public OzoneInputStream readFile(String volumeName, String bucketName, String keyName,
+      boolean requireFileSystemLayout) throws IOException {
+    return null;
+  }
+
   @Override
   public OzoneOutputStream createFile(String volumeName, String bucketName,
                                       String keyName, long size,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@ozone.apache.org
For additional commands, e-mail: commits-help@ozone.apache.org