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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3e1807e75bd HBASE-30161 Add paginated, single-RPC 
RegionLocator.getRegionLocations(startKey, limit) API for bulk meta-cache 
warmup (#8237)
3e1807e75bd is described below

commit 3e1807e75bd96aa391d302c376d9e9642020b1ea
Author: sanjeet006py <[email protected]>
AuthorDate: Wed May 27 05:47:39 2026 +0530

    HBASE-30161 Add paginated, single-RPC 
RegionLocator.getRegionLocations(startKey, limit) API for bulk meta-cache 
warmup (#8237)
    
    Signed-off-by: Viraj Jasani <[email protected]>
    
    Generated-by: Claude Opus 4.7 <[email protected]>
---
 .../hadoop/hbase/ClientMetaTableAccessor.java      |  98 +++++++++--
 .../hbase/client/AsyncNonMetaRegionLocator.java    |   5 +
 .../hbase/client/AsyncTableRegionLocator.java      |  56 ++++++
 .../hbase/client/AsyncTableRegionLocatorImpl.java  |  35 ++++
 .../client/ConnectionOverAsyncConnection.java      |   7 +
 .../apache/hadoop/hbase/client/RegionLocator.java  |  54 ++++++
 .../RegionLocatorOverAsyncTableRegionLocator.java  |   6 +
 .../hbase/client/AbstractTestRegionLocator.java    | 104 +++++++++++
 .../hbase/client/TestAsyncTableRegionLocator.java  |  13 ++
 .../hadoop/hbase/client/TestRegionLocator.java     |  16 ++
 .../client/TestRegionLocatorPagedScanRpcCount.java | 190 +++++++++++++++++++++
 11 files changed, 570 insertions(+), 14 deletions(-)

diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/ClientMetaTableAccessor.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/ClientMetaTableAccessor.java
index 42bfd757e0d..6e6ddb51e02 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/ClientMetaTableAccessor.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/ClientMetaTableAccessor.java
@@ -168,8 +168,34 @@ public final class ClientMetaTableAccessor {
    */
   public static CompletableFuture<List<HRegionLocation>> 
getTableHRegionLocations(
     AsyncTable<AdvancedScanResultConsumer> metaTable, TableName tableName) {
+    return toHRegionLocations(getTableRegionsAndLocations(metaTable, 
tableName, true));
+  }
+
+  /**
+   * Used to get a single-RPC, paginated slice of region locations for the 
specific table, starting
+   * at the meta row derived from {@code startKey} and capped at {@code 
rowLimit} regions.
+   * {@code startKey} must be a region start-key boundary (e.g. the end key of 
the previously
+   * visited region), or {@code null}/empty to start at the first region.
+   * @param metaTable scanner over meta table
+   * @param tableName table we're looking for
+   * @param startKey  region start-key to begin scanning from (inclusive); 
{@code null} or empty
+   *                  starts from the first region
+   * @param rowLimit  maximum number of meta rows to return; if {@code <= 0}, 
the underlying scan is
+   *                  unbounded
+   * @return the list of region locations. The return value will be wrapped by 
a
+   *         {@link CompletableFuture}.
+   */
+  public static CompletableFuture<List<HRegionLocation>> 
getTableHRegionLocations(
+    AsyncTable<AdvancedScanResultConsumer> metaTable, TableName tableName, 
byte[] startKey,
+    int rowLimit) {
+    return toHRegionLocations(
+      getTableRegionsAndLocations(metaTable, tableName, true, startKey, 
rowLimit));
+  }
+
+  private static CompletableFuture<List<HRegionLocation>>
+    toHRegionLocations(CompletableFuture<List<Pair<RegionInfo, ServerName>>> 
source) {
     CompletableFuture<List<HRegionLocation>> future = new 
CompletableFuture<>();
-    addListener(getTableRegionsAndLocations(metaTable, tableName, true), 
(locations, err) -> {
+    addListener(source, (locations, err) -> {
       if (err != null) {
         future.completeExceptionally(err);
       } else if (locations == null || locations.isEmpty()) {
@@ -215,6 +241,39 @@ public final class ClientMetaTableAccessor {
     return future;
   }
 
+  /**
+   * Variant of {@link #getTableRegionsAndLocations} that scans a bounded 
slice of meta starting at
+   * the row derived from {@code startKey} and stopping after at most {@code 
rowLimit} rows.
+   */
+  private static CompletableFuture<List<Pair<RegionInfo, ServerName>>> 
getTableRegionsAndLocations(
+    final AsyncTable<AdvancedScanResultConsumer> metaTable, final TableName 
tableName,
+    final boolean excludeOfflinedSplitParents, final byte[] startKey, final 
int rowLimit) {
+    CompletableFuture<List<Pair<RegionInfo, ServerName>>> future = new 
CompletableFuture<>();
+    if (TableName.META_TABLE_NAME.equals(tableName)) {
+      future.completeExceptionally(new IOException(
+        "This method can't be used to locate meta regions;" + " use 
MetaTableLocator instead"));
+      return future;
+    }
+
+    CollectRegionLocationsVisitor visitor =
+      new CollectRegionLocationsVisitor(excludeOfflinedSplitParents);
+
+    byte[] metaStart = (startKey == null || startKey.length == 0)
+      ? getTableStartRowForMeta(tableName, QueryType.REGION)
+      : RegionInfo.createRegionName(tableName, startKey, HConstants.ZEROES, 
false);
+    byte[] metaStop = getTableStopRowForMeta(tableName, QueryType.REGION);
+
+    addListener(scanMeta(metaTable, metaStart, metaStop, QueryType.REGION, 
rowLimit, true, visitor),
+      (v, error) -> {
+        if (error != null) {
+          future.completeExceptionally(error);
+          return;
+        }
+        future.complete(visitor.getResults());
+      });
+    return future;
+  }
+
   /**
    * Performs a scan of META table for given table.
    * @param metaTable scanner over meta table
@@ -225,22 +284,26 @@ public final class ClientMetaTableAccessor {
   private static CompletableFuture<Void> 
scanMeta(AsyncTable<AdvancedScanResultConsumer> metaTable,
     TableName tableName, QueryType type, final Visitor visitor) {
     return scanMeta(metaTable, getTableStartRowForMeta(tableName, type),
-      getTableStopRowForMeta(tableName, type), type, Integer.MAX_VALUE, 
visitor);
+      getTableStopRowForMeta(tableName, type), type, Integer.MAX_VALUE, false, 
visitor);
   }
 
   /**
    * Performs a scan of META table for given table.
-   * @param metaTable scanner over meta table
-   * @param startRow  Where to start the scan
-   * @param stopRow   Where to stop the scan
-   * @param type      scanned part of meta
-   * @param maxRows   maximum rows to return
-   * @param visitor   Visitor invoked against each row
+   * @param metaTable   scanner over meta table
+   * @param startRow    Where to start the scan
+   * @param stopRow     Where to stop the scan
+   * @param type        scanned part of meta
+   * @param maxRows     maximum rows to return
+   * @param isPagedScan when {@code true}, the scan is sized so the whole 
slice (up to
+   *                    {@code maxRows}) returns in a single ScannerNext RPC. 
When {@code false},
+   *                    uses the configured {@code hbase.meta.scanner.caching}.
+   * @param visitor     Visitor invoked against each row
    */
   private static CompletableFuture<Void> 
scanMeta(AsyncTable<AdvancedScanResultConsumer> metaTable,
-    byte[] startRow, byte[] stopRow, QueryType type, int maxRows, final 
Visitor visitor) {
+    byte[] startRow, byte[] stopRow, QueryType type, int maxRows, boolean 
isPagedScan,
+    final Visitor visitor) {
     int rowUpperLimit = maxRows > 0 ? maxRows : Integer.MAX_VALUE;
-    Scan scan = getMetaScan(metaTable, rowUpperLimit);
+    Scan scan = getMetaScan(metaTable, rowUpperLimit, isPagedScan);
     for (byte[] family : type.getFamilies()) {
       scan.addFamily(family);
     }
@@ -437,7 +500,7 @@ public final class ClientMetaTableAccessor {
     }
   }
 
-  private static Scan getMetaScan(AsyncTable<?> metaTable, int rowUpperLimit) {
+  private static Scan getMetaScan(AsyncTable<?> metaTable, int rowUpperLimit, 
boolean isPagedScan) {
     Scan scan = new Scan();
     int scannerCaching = 
metaTable.getConfiguration().getInt(HConstants.HBASE_META_SCANNER_CACHING,
       HConstants.DEFAULT_HBASE_META_SCANNER_CACHING);
@@ -447,11 +510,18 @@ public final class ClientMetaTableAccessor {
     ) {
       scan.setConsistency(Consistency.TIMELINE);
     }
-    if (rowUpperLimit <= scannerCaching) {
+    if (isPagedScan) {
+      // Caller is doing a bounded paged scan and expects the whole slice back 
in one ScannerNext
+      // RPC. Size caching to the slice. Trade-off: a single larger response 
uses more RegionServer
+      // heap, fine for meta rows (small).
       scan.setLimit(rowUpperLimit);
+      scan.setCaching(rowUpperLimit);
+    } else {
+      if (rowUpperLimit <= scannerCaching) {
+        scan.setLimit(rowUpperLimit);
+      }
+      scan.setCaching(Math.min(rowUpperLimit, scannerCaching));
     }
-    int rows = Math.min(rowUpperLimit, scannerCaching);
-    scan.setCaching(rows);
     return scan;
   }
 
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncNonMetaRegionLocator.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncNonMetaRegionLocator.java
index e26fb837b89..29ad79fc2ea 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncNonMetaRegionLocator.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncNonMetaRegionLocator.java
@@ -578,6 +578,11 @@ class AsyncNonMetaRegionLocator {
     
getTableCache(loc.getRegion().getTable()).regionLocationCache.add(createRegionLocations(loc));
   }
 
+  RegionLocations getCachedLocation(TableName tableName, byte[] startKey) {
+    TableCache tableCache = cache.get(tableName);
+    return tableCache == null ? null : 
tableCache.regionLocationCache.get(startKey);
+  }
+
   private HRegionLocation getCachedLocation(HRegionLocation loc) {
     TableCache tableCache = cache.get(loc.getRegion().getTable());
     if (tableCache == null) {
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocator.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocator.java
index fffca5c2dcc..5fa702d2351 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocator.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocator.java
@@ -20,6 +20,7 @@ package org.apache.hadoop.hbase.client;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
+import org.apache.hadoop.hbase.HConstants;
 import org.apache.hadoop.hbase.HRegionLocation;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.hadoop.hbase.util.Pair;
@@ -112,6 +113,61 @@ public interface AsyncTableRegionLocator {
    */
   CompletableFuture<List<HRegionLocation>> getAllRegionLocations();
 
+  /**
+   * Bulk lookup of region locations from {@code hbase:meta} in a single RPC, 
starting at
+   * {@code startKey} (region start-key boundary, inclusive) and returning at 
most {@code limit}
+   * regions in start-key order.
+   * <p/>
+   * The returned list includes all replicas of each region (matching
+   * {@link #getAllRegionLocations()}), and the result is also written to the 
connection's region
+   * location cache.
+   * <p/>
+   * Ordering: regions are returned in ascending region start-key order (the 
natural order of
+   * {@code hbase:meta} rows for a single table). Within each region, replicas 
are returned in
+   * ascending replica-id order (replica 0, then 1, then 2, ...). Split 
parents are filtered out,
+   * which may cause a page to contain fewer than {@code limit} regions but 
never disturbs ordering
+   * of the survivors.
+   * <p/>
+   * To page through all regions of a table, call repeatedly passing
+   * {@code last.getRegion().getEndKey()} as the next {@code startKey}, where 
{@code last} is the
+   * final element of the previous response. All replicas of a region share 
the same
+   * {@link RegionInfo}, so the last entry's end key is the correct cursor 
regardless of which
+   * replica it is. Pass {@code null} for the first call. Stop paging when the 
returned list is
+   * empty or when the last region's end key is {@link 
HConstants#EMPTY_END_ROW} (zero-length) -
+   * that signals the end of the table; passing it back in would re-scan from 
the beginning since by
+   * convention an empty start key means "from the first region".
+   * <p/>
+   * Unlike {@link #getAllRegionLocations()}, this method performs at most one 
RPC against
+   * {@code hbase:meta} per invocation, so its latency is bounded by {@code 
limit} rather than table
+   * size. The single-RPC behavior is best-effort: if the response would exceed
+   * {@code hbase.client.scanner.max.result.size} (default 2 MB), the server 
may split the slice
+   * across multiple {@code ScannerNext} RPCs. For typical meta row sizes and 
default caching this
+   * rarely fires, but callers passing large {@code limit} values against 
clusters with replicas or
+   * heavy meta rows should treat single-RPC as a soft guarantee, not 
absolute. Note that this
+   * method does not coordinate with other in-flight meta lookups on the 
connection - aggregate
+   * pacing across concurrent callers is the caller's responsibility.
+   * <p/>
+   * This method is optional. Implementations that cannot support paginated 
lookups will return a
+   * future that completes exceptionally with {@link 
UnsupportedOperationException} (the default
+   * behavior); callers should fall back to {@link #getAllRegionLocations()} 
in that case.
+   * @param startKey region start-key to begin scanning from (inclusive); 
{@code null} or empty
+   *                 starts from the first region
+   * @param limit    maximum number of regions to return. If &lt;= 0, falls 
back to
+   *                 {@code hbase.meta.scanner.caching} - this is a SOFT cap 
on a single page, NOT
+   *                 "all regions"; tables larger than the cap still require 
the caller to keep
+   *                 paging via {@code last.getRegion().getEndKey()}.
+   * @return up to {@code limit} {@link HRegionLocation}s in start-key order, 
possibly empty when no
+   *         more regions exist; errors are reported via the returned future
+   */
+  default CompletableFuture<List<HRegionLocation>> 
getRegionLocationsPage(byte[] startKey,
+    int limit) {
+    CompletableFuture<List<HRegionLocation>> failed = new 
CompletableFuture<>();
+    failed.completeExceptionally(new UnsupportedOperationException(
+      "getRegionLocationsPage(byte[], int) is not supported by this 
AsyncTableRegionLocator;"
+        + " fall back to getAllRegionLocations()"));
+    return failed;
+  }
+
   /**
    * Gets the starting row key for every region in the currently open table.
    * <p>
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocatorImpl.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocatorImpl.java
index b7ec7fcd872..1f05e5eb2a2 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocatorImpl.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncTableRegionLocatorImpl.java
@@ -20,10 +20,12 @@ package org.apache.hadoop.hbase.client;
 import static org.apache.hadoop.hbase.trace.TraceUtil.tracedFuture;
 import static org.apache.hadoop.hbase.util.FutureUtils.addListener;
 
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import org.apache.hadoop.hbase.ClientMetaTableAccessor;
+import org.apache.hadoop.hbase.HConstants;
 import org.apache.hadoop.hbase.HRegionLocation;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.yetus.audience.InterfaceAudience;
@@ -81,6 +83,39 @@ class AsyncTableRegionLocatorImpl implements 
AsyncTableRegionLocator {
       .thenApply(locs -> Arrays.asList(locs.getRegionLocations()));
   }
 
+  @Override
+  public CompletableFuture<List<HRegionLocation>> 
getRegionLocationsPage(byte[] startKey,
+    int limit) {
+    return tracedFuture(() -> {
+      if (TableName.isMetaTableName(tableName)) {
+        CompletableFuture<List<HRegionLocation>> failed = new 
CompletableFuture<>();
+        failed.completeExceptionally(
+          new IOException("getRegionLocationsPage(startKey, limit) is not 
supported for hbase:meta;"
+            + " use getRegionLocation(EMPTY_START_ROW) instead."));
+        return failed;
+      }
+      int effectiveLimit = limit > 0
+        ? limit
+        : conn.getConfiguration().getInt(HConstants.HBASE_META_SCANNER_CACHING,
+          HConstants.DEFAULT_HBASE_META_SCANNER_CACHING);
+      CompletableFuture<List<HRegionLocation>> future =
+        
ClientMetaTableAccessor.getTableHRegionLocations(conn.getTable(TableName.META_TABLE_NAME),
+          tableName, startKey, effectiveLimit);
+      addListener(future, (locs, error) -> {
+        if (error != null || locs == null) {
+          return;
+        }
+        for (HRegionLocation loc : locs) {
+          // the cache assumes that all locations have a serverName. only add 
if that's true
+          if (loc.getServerName() != null) {
+            
conn.getLocator().getNonMetaRegionLocator().addLocationToCache(loc);
+          }
+        }
+      });
+      return future;
+    }, getClass().getSimpleName() + ".getRegionLocationsPage");
+  }
+
   @Override
   public void clearRegionLocationCache() {
     conn.getLocator().clearCache(tableName);
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/ConnectionOverAsyncConnection.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/ConnectionOverAsyncConnection.java
index 471cfa87445..72812f2cae3 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/ConnectionOverAsyncConnection.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/ConnectionOverAsyncConnection.java
@@ -17,6 +17,7 @@
  */
 package org.apache.hadoop.hbase.client;
 
+import com.google.errorprone.annotations.RestrictedApi;
 import java.io.IOException;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ExecutorService;
@@ -63,6 +64,12 @@ class ConnectionOverAsyncConnection implements Connection {
     this.connConf = new ConnectionConfiguration(conn.getConfiguration());
   }
 
+  @RestrictedApi(explanation = "Should only be called in tests", link = "",
+      allowedOnPath = ".*/src/test/.*")
+  AsyncConnectionImpl getAsyncConnection() {
+    return conn;
+  }
+
   @Override
   public void abort(String why, Throwable error) {
     if (error != null) {
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocator.java 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocator.java
index 40f31b06f25..3a197c16e1a 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocator.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocator.java
@@ -21,6 +21,7 @@ import java.io.Closeable;
 import java.io.IOException;
 import java.util.List;
 import java.util.stream.Collectors;
+import org.apache.hadoop.hbase.HConstants;
 import org.apache.hadoop.hbase.HRegionLocation;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.hadoop.hbase.util.Pair;
@@ -130,6 +131,59 @@ public interface RegionLocator extends Closeable {
    */
   List<HRegionLocation> getAllRegionLocations() throws IOException;
 
+  /**
+   * Bulk lookup of region locations from {@code hbase:meta} in a single RPC, 
starting at
+   * {@code startKey} (region start-key boundary, inclusive) and returning at 
most {@code limit}
+   * regions in start-key order.
+   * <p/>
+   * The returned list includes all replicas of each region (matching
+   * {@link #getAllRegionLocations()}), and the result is also written to the 
connection's region
+   * location cache.
+   * <p/>
+   * Ordering: regions are returned in ascending region start-key order (the 
natural order of
+   * {@code hbase:meta} rows for a single table). Within each region, replicas 
are returned in
+   * ascending replica-id order (replica 0, then 1, then 2, ...). Split 
parents are filtered out,
+   * which may cause a page to contain fewer than {@code limit} regions but 
never disturbs ordering
+   * of the survivors.
+   * <p/>
+   * To page through all regions of a table, call repeatedly passing
+   * {@code last.getRegion().getEndKey()} as the next {@code startKey}, where 
{@code last} is the
+   * final element of the previous response. All replicas of a region share 
the same
+   * {@link RegionInfo}, so the last entry's end key is the correct cursor 
regardless of which
+   * replica it is. Pass {@code null} for the first call. Stop paging when the 
returned list is
+   * empty or when the last region's end key is {@link 
HConstants#EMPTY_END_ROW} (zero-length) -
+   * that signals the end of the table; passing it back in would re-scan from 
the beginning since by
+   * convention an empty start key means "from the first region".
+   * <p/>
+   * Unlike {@link #getAllRegionLocations()}, this method performs at most one 
RPC against
+   * {@code hbase:meta} per invocation, so its latency is bounded by {@code 
limit} rather than table
+   * size. The single-RPC behavior is best-effort: if the response would exceed
+   * {@code hbase.client.scanner.max.result.size} (default 2 MB), the server 
may split the slice
+   * across multiple {@code ScannerNext} RPCs. For typical meta row sizes and 
default caching this
+   * rarely fires, but callers passing large {@code limit} values against 
clusters with replicas or
+   * heavy meta rows should treat single-RPC as a soft guarantee, not absolute.
+   * <p/>
+   * This method is optional. Implementations that cannot support paginated 
lookups should throw
+   * {@link UnsupportedOperationException} (the default behavior); callers 
should fall back to
+   * {@link #getAllRegionLocations()} in that case.
+   * @param startKey region start-key to begin scanning from (inclusive); 
{@code null} or empty
+   *                 starts from the first region
+   * @param limit    maximum number of regions to return. If &lt;= 0, falls 
back to
+   *                 {@code hbase.meta.scanner.caching} - this is a SOFT cap 
on a single page, NOT
+   *                 "all regions"; tables larger than the cap still require 
the caller to keep
+   *                 paging via {@code last.getRegion().getEndKey()}.
+   * @return up to {@code limit} {@link HRegionLocation}s in start-key order, 
possibly empty when no
+   *         more regions exist
+   * @throws IOException                   if a remote or network exception 
occurs
+   * @throws UnsupportedOperationException if this implementation does not 
support paginated lookups
+   */
+  default List<HRegionLocation> getRegionLocationsPage(byte[] startKey, int 
limit)
+    throws IOException {
+    throw new UnsupportedOperationException(
+      "getRegionLocationsPage(byte[], int) is not supported by this 
RegionLocator;"
+        + " fall back to getAllRegionLocations()");
+  }
+
   /**
    * Gets the starting row key for every region in the currently open table.
    * <p>
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocatorOverAsyncTableRegionLocator.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocatorOverAsyncTableRegionLocator.java
index 0cf0e0b913f..3ec5f83db57 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocatorOverAsyncTableRegionLocator.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RegionLocatorOverAsyncTableRegionLocator.java
@@ -62,6 +62,12 @@ class RegionLocatorOverAsyncTableRegionLocator implements 
RegionLocator {
     return get(locator.getAllRegionLocations());
   }
 
+  @Override
+  public List<HRegionLocation> getRegionLocationsPage(byte[] startKey, int 
limit)
+    throws IOException {
+    return get(locator.getRegionLocationsPage(startKey, limit));
+  }
+
   @Override
   public TableName getName() {
     return locator.getName();
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/AbstractTestRegionLocator.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/AbstractTestRegionLocator.java
index b234071f3b3..515d078367c 100644
--- 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/AbstractTestRegionLocator.java
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/AbstractTestRegionLocator.java
@@ -19,13 +19,18 @@ package org.apache.hadoop.hbase.client;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+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 java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import org.apache.hadoop.hbase.HBaseTestingUtil;
 import org.apache.hadoop.hbase.HConstants;
 import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.RegionLocations;
 import org.apache.hadoop.hbase.ServerName;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.hadoop.hbase.regionserver.Region;
@@ -41,6 +46,13 @@ public abstract class AbstractTestRegionLocator {
 
   protected static TableName TABLE_NAME = TableName.valueOf("Locator");
 
+  /**
+   * Single-replica companion table used only by tests that need to assert 
per-replica cache
+   * population without tripping over the multi-replica
+   * {@link AsyncNonMetaRegionLocator#addLocationToCache(HRegionLocation)} 
merge limitation.
+   */
+  protected static TableName TABLE_NAME_NO_REPLICA = 
TableName.valueOf("LocatorNoReplica");
+
   protected static byte[] FAMILY = Bytes.toBytes("family");
 
   protected static int REGION_REPLICATION = 3;
@@ -59,6 +71,10 @@ public abstract class AbstractTestRegionLocator {
     }
     UTIL.getAdmin().createTable(td, SPLIT_KEYS);
     UTIL.waitTableAvailable(TABLE_NAME);
+    TableDescriptor tdNoReplica = 
TableDescriptorBuilder.newBuilder(TABLE_NAME_NO_REPLICA)
+      .setColumnFamily(ColumnFamilyDescriptorBuilder.of(FAMILY)).build();
+    UTIL.getAdmin().createTable(tdNoReplica, SPLIT_KEYS);
+    UTIL.waitTableAvailable(TABLE_NAME_NO_REPLICA);
     try (ConnectionRegistry registry =
       ConnectionRegistryFactory.create(UTIL.getConfiguration(), 
User.getCurrent())) {
       RegionReplicaTestHelper.waitUntilAllMetaReplicasAreReady(UTIL, registry);
@@ -69,6 +85,7 @@ public abstract class AbstractTestRegionLocator {
   @AfterEach
   public void tearDownAfterTest() throws IOException {
     clearCache(TABLE_NAME);
+    clearCache(TABLE_NAME_NO_REPLICA);
     clearCache(TableName.META_TABLE_NAME);
   }
 
@@ -160,6 +177,87 @@ public abstract class AbstractTestRegionLocator {
     }
   }
 
+  @Test
+  public void testGetRegionLocationsFirstPage() throws IOException {
+    List<HRegionLocation> page = getRegionLocationsPage(TABLE_NAME, null, 2);
+    assertEquals(2 * REGION_REPLICATION, page.size());
+    for (int i = 0; i < 2; i++) {
+      for (int replicaId = 0; replicaId < REGION_REPLICATION; replicaId++) {
+        assertRegionLocation(page.get(i * REGION_REPLICATION + replicaId), i, 
replicaId);
+      }
+    }
+  }
+
+  @Test
+  public void testGetRegionLocationsPagination() throws IOException {
+    int pageSize = 3;
+    int totalRegions = SPLIT_KEYS.length + 1;
+    List<HRegionLocation> all = new ArrayList<>();
+    byte[] cursor = null;
+    while (true) {
+      List<HRegionLocation> page = getRegionLocationsPage(TABLE_NAME, cursor, 
pageSize);
+      if (page.isEmpty()) {
+        break;
+      }
+      all.addAll(page);
+      HRegionLocation last = page.get(page.size() - 1);
+      byte[] endKey = last.getRegion().getEndKey();
+      if (endKey.length == 0) {
+        break;
+      }
+      cursor = endKey;
+    }
+    assertEquals(totalRegions * REGION_REPLICATION, all.size());
+    for (int i = 0; i <= SPLIT_KEYS.length; i++) {
+      for (int replicaId = 0; replicaId < REGION_REPLICATION; replicaId++) {
+        assertRegionLocation(all.get(i * REGION_REPLICATION + replicaId), i, 
replicaId);
+      }
+    }
+  }
+
+  @Test
+  public void testGetRegionLocationsEmptyAfterEnd() throws IOException {
+    // Use a startKey lexicographically after all split keys: SPLIT_KEYS go 
"1".."9", so "z".
+    List<HRegionLocation> page = getRegionLocationsPage(TABLE_NAME, 
Bytes.toBytes("z"), 5);
+    assertTrue(page.isEmpty(),
+      "expected empty page past the last region; got " + page.size() + " 
entries");
+  }
+
+  @Test
+  public void testGetRegionLocationsLimitFallsBackToConfig() throws 
IOException {
+    // mini-cluster default for hbase.meta.scanner.caching is well above 
SPLIT_KEYS.length+1, so
+    // limit<=0 must return every region.
+    List<HRegionLocation> page = getRegionLocationsPage(TABLE_NAME, null, 0);
+    assertEquals((SPLIT_KEYS.length + 1) * REGION_REPLICATION, page.size());
+    page = getRegionLocationsPage(TABLE_NAME, null, -1);
+    assertEquals((SPLIT_KEYS.length + 1) * REGION_REPLICATION, page.size());
+  }
+
+  @Test
+  public void testGetRegionLocationsRejectsMeta() {
+    assertThrows(IOException.class,
+      () -> getRegionLocationsPage(TableName.META_TABLE_NAME, 
HConstants.EMPTY_START_ROW, 1));
+  }
+
+  @Test
+  public void testGetRegionLocationsPopulatesCache() throws IOException {
+    // Use the single-replica companion table so the multi-replica
+    // addLocationToCache merge limitation does not interfere with this test.
+    clearCache(TABLE_NAME_NO_REPLICA);
+    List<HRegionLocation> page = getRegionLocationsPage(TABLE_NAME_NO_REPLICA, 
null, 3);
+    assertEquals(3, page.size());
+    for (HRegionLocation loc : page) {
+      byte[] startKey = loc.getRegion().getStartKey();
+      RegionLocations cached = getCachedLocation(TABLE_NAME_NO_REPLICA, 
startKey);
+      assertNotNull(cached, "metaCache miss for region starting at "
+        + Bytes.toStringBinary(startKey) + " — bulk API did not populate the 
cache");
+      HRegionLocation cachedLoc = 
cached.getRegionLocation(RegionInfo.DEFAULT_REPLICA_ID);
+      assertNotNull(cachedLoc, "metaCache had region but missing default 
replica entry");
+      assertEquals(loc.getServerName(), cachedLoc.getServerName(),
+        "cached server differs from server returned by bulk API");
+    }
+  }
+
   private void assertMetaStartOrEndKeys(byte[][] keys) {
     assertEquals(1, keys.length);
     assertArrayEquals(HConstants.EMPTY_BYTE_ARRAY, keys[0]);
@@ -215,5 +313,11 @@ public abstract class AbstractTestRegionLocator {
   protected abstract List<HRegionLocation> getAllRegionLocations(TableName 
tableName)
     throws IOException;
 
+  protected abstract List<HRegionLocation> getRegionLocationsPage(TableName 
tableName,
+    byte[] startKey, int limit) throws IOException;
+
+  protected abstract RegionLocations getCachedLocation(TableName tableName, 
byte[] startKey)
+    throws IOException;
+
   protected abstract void clearCache(TableName tableName) throws IOException;
 }
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestAsyncTableRegionLocator.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestAsyncTableRegionLocator.java
index 7f29fa5f665..7113f489c90 100644
--- 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestAsyncTableRegionLocator.java
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestAsyncTableRegionLocator.java
@@ -22,6 +22,7 @@ import static org.apache.hadoop.hbase.util.FutureUtils.get;
 import java.io.IOException;
 import java.util.List;
 import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.RegionLocations;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.hadoop.hbase.testclassification.ClientTests;
 import org.apache.hadoop.hbase.testclassification.MediumTests;
@@ -91,6 +92,18 @@ public class TestAsyncTableRegionLocator extends 
AbstractTestRegionLocator {
     return get(CONN.getRegionLocator(tableName).getAllRegionLocations());
   }
 
+  @Override
+  protected List<HRegionLocation> getRegionLocationsPage(TableName tableName, 
byte[] startKey,
+    int limit) throws IOException {
+    return 
get(CONN.getRegionLocator(tableName).getRegionLocationsPage(startKey, limit));
+  }
+
+  @Override
+  protected RegionLocations getCachedLocation(TableName tableName, byte[] 
startKey) {
+    return ((AsyncConnectionImpl) CONN).getLocator().getNonMetaRegionLocator()
+      .getCachedLocation(tableName, startKey);
+  }
+
   @Override
   protected void clearCache(TableName tableName) throws IOException {
     CONN.getRegionLocator(tableName).clearRegionLocationCache();
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocator.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocator.java
index e464344c0c6..165b6624aef 100644
--- 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocator.java
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocator.java
@@ -20,6 +20,7 @@ package org.apache.hadoop.hbase.client;
 import java.io.IOException;
 import java.util.List;
 import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.RegionLocations;
 import org.apache.hadoop.hbase.TableName;
 import org.apache.hadoop.hbase.testclassification.ClientTests;
 import org.apache.hadoop.hbase.testclassification.MediumTests;
@@ -86,6 +87,21 @@ public class TestRegionLocator extends 
AbstractTestRegionLocator {
     }
   }
 
+  @Override
+  protected List<HRegionLocation> getRegionLocationsPage(TableName tableName, 
byte[] startKey,
+    int limit) throws IOException {
+    try (RegionLocator locator = 
UTIL.getConnection().getRegionLocator(tableName)) {
+      return locator.getRegionLocationsPage(startKey, limit);
+    }
+  }
+
+  @Override
+  protected RegionLocations getCachedLocation(TableName tableName, byte[] 
startKey)
+    throws IOException {
+    return ((ConnectionOverAsyncConnection) 
UTIL.getConnection()).getAsyncConnection().getLocator()
+      .getNonMetaRegionLocator().getCachedLocation(tableName, startKey);
+  }
+
   @Override
   protected void clearCache(TableName tableName) throws IOException {
     try (RegionLocator locator = 
UTIL.getConnection().getRegionLocator(tableName)) {
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocatorPagedScanRpcCount.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocatorPagedScanRpcCount.java
new file mode 100644
index 00000000000..6c63ec7cb03
--- /dev/null
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/TestRegionLocatorPagedScanRpcCount.java
@@ -0,0 +1,190 @@
+/*
+ * 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.hbase.client;
+
+import static org.apache.hadoop.hbase.util.FutureUtils.get;
+import static org.junit.Assert.assertEquals;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hadoop.hbase.ClientMetaTableAccessor;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.metrics.ScanMetrics;
+import org.apache.hadoop.hbase.testclassification.ClientTests;
+import org.apache.hadoop.hbase.testclassification.MediumTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import org.apache.hbase.thirdparty.com.google.common.io.Closeables;
+
+/**
+ * Asserts the single-RPC promise of the paginated meta-scan path
+ * ({@link ClientMetaTableAccessor#getTableHRegionLocations(AsyncTable, 
TableName, byte[], int)}) by
+ * wrapping the meta {@link AsyncTable} so we can count {@link 
AdvancedScanResultConsumer#onNext}
+ * invocations - one per ScannerNext server response.
+ * <p/>
+ * Cluster runs with {@code hbase.meta.scanner.caching = 2} so the {@code 
limit > caching} branch is
+ * exercised cheaply with a small table (5 user regions).
+ */
+@Category({ MediumTests.class, ClientTests.class })
+public class TestRegionLocatorPagedScanRpcCount {
+
+  @ClassRule
+  public static final HBaseClassTestRule CLASS_RULE =
+    HBaseClassTestRule.forClass(TestRegionLocatorPagedScanRpcCount.class);
+
+  private static final HBaseTestingUtil UTIL = new HBaseTestingUtil();
+  private static final TableName TABLE_NAME = 
TableName.valueOf("LocatorPaged");
+  private static final byte[] FAMILY = Bytes.toBytes("family");
+
+  /** Caching is small enough that {@code limit > META_CACHING} is easy to set 
up. */
+  private static final int META_CACHING = 2;
+  private static final int NUM_REGIONS = 5;
+
+  private static AsyncConnection CONN;
+
+  @BeforeClass
+  public static void setUp() throws Exception {
+    UTIL.getConfiguration().setInt(HConstants.HBASE_META_SCANNER_CACHING, 
META_CACHING);
+    UTIL.startMiniCluster(1);
+    byte[][] splitKeys = new byte[NUM_REGIONS - 1][];
+    for (int i = 0; i < splitKeys.length; i++) {
+      splitKeys[i] = Bytes.toBytes(Integer.toString(i + 1));
+    }
+    TableDescriptor td = TableDescriptorBuilder.newBuilder(TABLE_NAME)
+      .setColumnFamily(ColumnFamilyDescriptorBuilder.of(FAMILY)).build();
+    UTIL.getAdmin().createTable(td, splitKeys);
+    UTIL.waitTableAvailable(TABLE_NAME);
+    UTIL.getAdmin().balancerSwitch(false, true);
+    CONN = 
ConnectionFactory.createAsyncConnection(UTIL.getConfiguration()).get();
+  }
+
+  @AfterClass
+  public static void tearDown() throws Exception {
+    Closeables.close(CONN, true);
+    UTIL.shutdownMiniCluster();
+  }
+
+  @Test
+  public void testSingleRpcWhenLimitWithinCaching() throws Exception {
+    // limit (2) <= caching (2): trivially one ScannerNext. Baseline.
+    int rpcs = runPagedScanAndCountRpcs(2);
+    assertEquals("expected exactly one ScannerNext RPC for limit <= caching", 
1, rpcs);
+  }
+
+  @Test
+  public void testSingleRpcWhenLimitExceedsCaching() throws Exception {
+    // limit (5) > caching (2): without the isPagedScan fix this would be 
ceil(5/2) = 3
+    // ScannerNext RPCs. With the fix, getMetaScan sizes caching to limit -> 1 
RPC.
+    int rpcs = runPagedScanAndCountRpcs(NUM_REGIONS);
+    assertEquals("expected exactly one ScannerNext RPC for paged scan even 
when limit > caching", 1,
+      rpcs);
+  }
+
+  @Test
+  public void testUnboundedPathStillUsesConfiguredCaching() throws Exception {
+    // The unbounded getTableHRegionLocations(metaTable, tableName) overload 
(no limit) must
+    // continue to use the configured caching (META_CACHING=2). For 
NUM_REGIONS=5 user regions,
+    // expect ceil(5 / 2) = 3 ScannerNext batches plus possibly a final empty 
batch when the
+    // server-side scan reaches the stopRow. Assert it is strictly more than 
one to prove the
+    // isPagedScan flag did not bleed into the unbounded path.
+    AtomicInteger onNextCalls = new AtomicInteger();
+    AsyncTable<AdvancedScanResultConsumer> metaTable = 
wrapMetaTable(onNextCalls);
+    List<HRegionLocation> all =
+      get(ClientMetaTableAccessor.getTableHRegionLocations(metaTable, 
TABLE_NAME));
+    assertEquals(NUM_REGIONS, all.size());
+    int rpcs = onNextCalls.get();
+    assertEquals(
+      "unbounded scan should still split across "
+        + "ceil(NUM_REGIONS / caching) ScannerNext batches; got " + rpcs,
+      (int) Math.ceil((double) NUM_REGIONS / META_CACHING), rpcs);
+  }
+
+  private int runPagedScanAndCountRpcs(int limit) throws Exception {
+    AtomicInteger onNextCalls = new AtomicInteger();
+    AsyncTable<AdvancedScanResultConsumer> metaTable = 
wrapMetaTable(onNextCalls);
+    List<HRegionLocation> page =
+      get(ClientMetaTableAccessor.getTableHRegionLocations(metaTable, 
TABLE_NAME, null, limit));
+    assertEquals("paged call returned wrong number of regions", limit, 
page.size());
+    return onNextCalls.get();
+  }
+
+  /**
+   * Returns a delegating proxy for the meta {@link AsyncTable} that intercepts
+   * {@code scan(Scan, AdvancedScanResultConsumer)} and wraps the supplied 
consumer so every
+   * {@link AdvancedScanResultConsumer#onNext} invocation increments {@code 
onNextCalls}.
+   */
+  @SuppressWarnings("unchecked")
+  private static AsyncTable<AdvancedScanResultConsumer> 
wrapMetaTable(AtomicInteger onNextCalls) {
+    AsyncTable<AdvancedScanResultConsumer> delegate = 
CONN.getTable(TableName.META_TABLE_NAME);
+    InvocationHandler handler = new InvocationHandler() {
+      @Override
+      public Object invoke(Object proxy, Method method, Object[] args) throws 
Throwable {
+        if (
+          "scan".equals(method.getName()) && args != null && args.length == 2
+            && args[1] instanceof AdvancedScanResultConsumer
+        ) {
+          Scan scan = (Scan) args[0];
+          AdvancedScanResultConsumer original = (AdvancedScanResultConsumer) 
args[1];
+          AdvancedScanResultConsumer counting = new 
AdvancedScanResultConsumer() {
+            @Override
+            public void onNext(Result[] results, ScanController controller) {
+              onNextCalls.incrementAndGet();
+              original.onNext(results, controller);
+            }
+
+            @Override
+            public void onError(Throwable error) {
+              original.onError(error);
+            }
+
+            @Override
+            public void onComplete() {
+              original.onComplete();
+            }
+
+            @Override
+            public void onHeartbeat(ScanController controller) {
+              original.onHeartbeat(controller);
+            }
+
+            @Override
+            public void onScanMetricsCreated(ScanMetrics scanMetrics) {
+              original.onScanMetricsCreated(scanMetrics);
+            }
+          };
+          return method.invoke(delegate, scan, counting);
+        }
+        return method.invoke(delegate, args);
+      }
+    };
+    return (AsyncTable<AdvancedScanResultConsumer>) Proxy.newProxyInstance(
+      AsyncTable.class.getClassLoader(), new Class<?>[] { AsyncTable.class }, 
handler);
+  }
+}


Reply via email to