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

andor pushed a commit to branch HBASE-29081
in repository https://gitbox.apache.org/repos/asf/hbase.git


The following commit(s) were added to refs/heads/HBASE-29081 by this push:
     new 7e4d270d331 HBASE-29291: Add a command to refresh/sync hbase:meta 
table (#7058)
7e4d270d331 is described below

commit 7e4d270d33131099ec0e0a0d7c0e039d2dd13c1d
Author: Kota-SH <[email protected]>
AuthorDate: Thu Sep 11 09:22:19 2025 -0500

    HBASE-29291: Add a command to refresh/sync hbase:meta table (#7058)
    
    Change-Id: Ia04bb12cdaf580f26cb14d9a34b5963105065faa
---
 .../java/org/apache/hadoop/hbase/client/Admin.java |   5 +
 .../hadoop/hbase/client/AdminOverAsyncAdmin.java   |   5 +
 .../org/apache/hadoop/hbase/client/AsyncAdmin.java |   5 +
 .../hadoop/hbase/client/AsyncHBaseAdmin.java       |   5 +
 .../hadoop/hbase/client/RawAsyncHBaseAdmin.java    |  14 +
 .../src/main/protobuf/server/master/Master.proto   |  11 +
 .../protobuf/server/master/MasterProcedure.proto   |  12 +
 .../org/apache/hadoop/hbase/MetaTableAccessor.java |   2 +-
 .../org/apache/hadoop/hbase/TableDescriptors.java  |   7 +
 .../org/apache/hadoop/hbase/master/HMaster.java    |  17 +
 .../hadoop/hbase/master/MasterRpcServices.java     |  11 +
 .../master/procedure/RefreshMetaProcedure.java     | 480 +++++++++++++++++++++
 .../hbase/security/access/ReadOnlyController.java  |  19 +-
 .../hadoop/hbase/util/FSTableDescriptors.java      |  10 +
 .../master/procedure/TestRefreshMetaProcedure.java | 121 ++++++
 .../TestRefreshMetaProcedureIntegration.java       | 285 ++++++++++++
 .../hbase/rsgroup/VerifyingRSGroupAdmin.java       |   5 +
 hbase-shell/src/main/ruby/hbase/admin.rb           |   6 +
 hbase-shell/src/main/ruby/shell.rb                 |   1 +
 .../src/main/ruby/shell/commands/refresh_meta.rb   |  43 ++
 .../hadoop/hbase/thrift2/client/ThriftAdmin.java   |   5 +
 21 files changed, 1064 insertions(+), 5 deletions(-)

diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/Admin.java 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/Admin.java
index 75dd2ef07b3..3d4e87b11a1 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/Admin.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/Admin.java
@@ -2651,4 +2651,9 @@ public interface Admin extends Abortable, Closeable {
    * Get the list of cached files
    */
   List<String> getCachedFilesList(ServerName serverName) throws IOException;
+
+  /**
+   * Perform hbase:meta table refresh
+   */
+  Long refreshMeta() throws IOException;
 }
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AdminOverAsyncAdmin.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AdminOverAsyncAdmin.java
index c13dfc33e3d..5dc01f240df 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AdminOverAsyncAdmin.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AdminOverAsyncAdmin.java
@@ -1136,4 +1136,9 @@ class AdminOverAsyncAdmin implements Admin {
   public List<String> getCachedFilesList(ServerName serverName) throws 
IOException {
     return get(admin.getCachedFilesList(serverName));
   }
+
+  @Override
+  public Long refreshMeta() throws IOException {
+    return get(admin.refreshMeta());
+  }
 }
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncAdmin.java 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncAdmin.java
index 331aa4a254a..45765a35f0c 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncAdmin.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncAdmin.java
@@ -1862,4 +1862,9 @@ public interface AsyncAdmin {
    * Get the list of cached files
    */
   CompletableFuture<List<String>> getCachedFilesList(ServerName serverName);
+
+  /**
+   * Perform hbase:meta table refresh
+   */
+  CompletableFuture<Long> refreshMeta();
 }
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncHBaseAdmin.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncHBaseAdmin.java
index 69f35360003..22b356e6e0d 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncHBaseAdmin.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/AsyncHBaseAdmin.java
@@ -1005,4 +1005,9 @@ class AsyncHBaseAdmin implements AsyncAdmin {
   public CompletableFuture<List<String>> getCachedFilesList(ServerName 
serverName) {
     return wrap(rawAdmin.getCachedFilesList(serverName));
   }
+
+  @Override
+  public CompletableFuture<Long> refreshMeta() {
+    return wrap(rawAdmin.refreshMeta());
+  }
 }
diff --git 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RawAsyncHBaseAdmin.java
 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RawAsyncHBaseAdmin.java
index 7cb0e468951..fa66abb264f 100644
--- 
a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RawAsyncHBaseAdmin.java
+++ 
b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/RawAsyncHBaseAdmin.java
@@ -261,6 +261,8 @@ import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.OfflineReg
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.OfflineRegionResponse;
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RecommissionRegionServerRequest;
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RecommissionRegionServerResponse;
+import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RefreshMetaRequest;
+import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RefreshMetaResponse;
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RestoreSnapshotRequest;
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RestoreSnapshotResponse;
 import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProtos.RunCatalogScanRequest;
@@ -4557,4 +4559,16 @@ class RawAsyncHBaseAdmin implements AsyncAdmin {
           resp -> resp.getCachedFilesList()))
       .serverName(serverName).call();
   }
+
+  @Override
+  public CompletableFuture<Long> refreshMeta() {
+    RefreshMetaRequest.Builder request = RefreshMetaRequest.newBuilder();
+    request.setNonceGroup(ng.getNonceGroup()).setNonce(ng.newNonce());
+    return this.<Long> newMasterCaller()
+      .action((controller, stub) -> this.<RefreshMetaRequest, 
RefreshMetaResponse, Long> call(
+        controller, stub, request.build(), 
MasterService.Interface::refreshMeta,
+        RefreshMetaResponse::getProcId))
+      .call();
+  }
+
 }
diff --git a/hbase-protocol-shaded/src/main/protobuf/server/master/Master.proto 
b/hbase-protocol-shaded/src/main/protobuf/server/master/Master.proto
index a8adaa27453..b39b0700aa1 100644
--- a/hbase-protocol-shaded/src/main/protobuf/server/master/Master.proto
+++ b/hbase-protocol-shaded/src/main/protobuf/server/master/Master.proto
@@ -799,6 +799,14 @@ message ModifyColumnStoreFileTrackerResponse {
 message FlushMasterStoreRequest {}
 message FlushMasterStoreResponse {}
 
+message RefreshMetaRequest {
+  optional uint64 nonce_group = 1 [default = 0];
+  optional uint64 nonce = 2 [default = 0];
+}
+message RefreshMetaResponse {
+  optional uint64 proc_id = 1;
+}
+
 service MasterService {
   /** Used by the client to get the number of regions that have received the 
updated schema */
   rpc GetSchemaAlterStatus(GetSchemaAlterStatusRequest)
@@ -1270,6 +1278,9 @@ service MasterService {
 
   rpc FlushTable(FlushTableRequest)
     returns(FlushTableResponse);
+
+  rpc RefreshMeta(RefreshMetaRequest)
+    returns(RefreshMetaResponse);
 }
 
 // HBCK Service definitions.
diff --git 
a/hbase-protocol-shaded/src/main/protobuf/server/master/MasterProcedure.proto 
b/hbase-protocol-shaded/src/main/protobuf/server/master/MasterProcedure.proto
index e3b43afd66a..53978a450df 100644
--- 
a/hbase-protocol-shaded/src/main/protobuf/server/master/MasterProcedure.proto
+++ 
b/hbase-protocol-shaded/src/main/protobuf/server/master/MasterProcedure.proto
@@ -839,3 +839,15 @@ message ReloadQuotasProcedureStateData {
   required ServerName target_server = 1;
   optional ForeignExceptionMessage error = 2;
 }
+
+enum RefreshMetaState {
+  REFRESH_META_INIT = 1;
+  REFRESH_META_SCAN_STORAGE = 2;
+  REFRESH_META_PREPARE = 3;
+  REFRESH_META_APPLY = 4;
+  REFRESH_META_FOLLOWUP = 5;
+  REFRESH_META_FINISH = 6;
+}
+
+message RefreshMetaStateData {
+}
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/MetaTableAccessor.java 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/MetaTableAccessor.java
index 98750d38a7c..9d31511a896 100644
--- a/hbase-server/src/main/java/org/apache/hadoop/hbase/MetaTableAccessor.java
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/MetaTableAccessor.java
@@ -742,7 +742,7 @@ public final class MetaTableAccessor {
    * @param connection connection we're using
    * @param deletes    Deletes to add to hbase:meta This list should support 
#remove.
    */
-  private static void deleteFromMetaTable(final Connection connection, final 
List<Delete> deletes)
+  public static void deleteFromMetaTable(final Connection connection, final 
List<Delete> deletes)
     throws IOException {
     try (Table t = getMetaHTable(connection)) {
       debugLogMutations(deletes);
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/TableDescriptors.java 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/TableDescriptors.java
index d22e46383d3..32594ffce48 100644
--- a/hbase-server/src/main/java/org/apache/hadoop/hbase/TableDescriptors.java
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/TableDescriptors.java
@@ -78,4 +78,11 @@ public interface TableDescriptors extends Closeable {
 
   /** Returns Instance of table descriptor or null if none found. */
   TableDescriptor remove(TableName tablename) throws IOException;
+
+  /**
+   * Invalidates the table descriptor cache.
+   */
+  default void invalidateTableDescriptorCache() {
+    // do nothing by default
+  }
 }
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/HMaster.java 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/HMaster.java
index f939f619013..5b1a69abc96 100644
--- a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/HMaster.java
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/HMaster.java
@@ -169,6 +169,7 @@ import 
org.apache.hadoop.hbase.master.procedure.ModifyTableProcedure;
 import org.apache.hadoop.hbase.master.procedure.ProcedurePrepareLatch;
 import org.apache.hadoop.hbase.master.procedure.ProcedureSyncWait;
 import org.apache.hadoop.hbase.master.procedure.RSProcedureDispatcher;
+import org.apache.hadoop.hbase.master.procedure.RefreshMetaProcedure;
 import org.apache.hadoop.hbase.master.procedure.ReloadQuotasProcedure;
 import org.apache.hadoop.hbase.master.procedure.ReopenTableRegionsProcedure;
 import org.apache.hadoop.hbase.master.procedure.ServerCrashProcedure;
@@ -4557,4 +4558,20 @@ public class HMaster extends 
HBaseServerBase<MasterRpcServices> implements Maste
         }
       });
   }
+
+  public Long refreshMeta(long nonceGroup, long nonce) throws IOException {
+    return MasterProcedureUtil
+      .submitProcedure(new MasterProcedureUtil.NonceProcedureRunnable(this, 
nonceGroup, nonce) {
+        @Override
+        protected void run() throws IOException {
+          LOG.info("Submitting RefreshMetaProcedure");
+          submitProcedure(new 
RefreshMetaProcedure(procedureExecutor.getEnvironment()));
+        }
+
+        @Override
+        protected String getDescription() {
+          return "RefreshMetaProcedure";
+        }
+      });
+  }
 }
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/MasterRpcServices.java
 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/MasterRpcServices.java
index fc246d38d51..6fbc42384b6 100644
--- 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/MasterRpcServices.java
+++ 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/MasterRpcServices.java
@@ -3662,4 +3662,15 @@ public class MasterRpcServices extends 
HBaseRpcServicesBase<HMaster>
       throw new ServiceException(ioe);
     }
   }
+
+  @Override
+  public MasterProtos.RefreshMetaResponse refreshMeta(RpcController controller,
+    MasterProtos.RefreshMetaRequest request) throws ServiceException {
+    try {
+      Long procId = server.refreshMeta(request.getNonceGroup(), 
request.getNonce());
+      return 
MasterProtos.RefreshMetaResponse.newBuilder().setProcId(procId).build();
+    } catch (IOException ioe) {
+      throw new ServiceException(ioe);
+    }
+  }
 }
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/procedure/RefreshMetaProcedure.java
 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/procedure/RefreshMetaProcedure.java
new file mode 100644
index 00000000000..b2e458cd495
--- /dev/null
+++ 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/procedure/RefreshMetaProcedure.java
@@ -0,0 +1,480 @@
+/*
+ * 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.master.procedure;
+
+import static 
org.apache.hadoop.hbase.shaded.protobuf.generated.ProcedureProtos.ProcedureState.WAITING_TIMEOUT;
+
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FSDataInputStream;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.MetaTableAccessor;
+import org.apache.hadoop.hbase.NamespaceDescriptor;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.Connection;
+import org.apache.hadoop.hbase.client.Delete;
+import org.apache.hadoop.hbase.client.Mutation;
+import org.apache.hadoop.hbase.client.Put;
+import org.apache.hadoop.hbase.client.RegionInfo;
+import org.apache.hadoop.hbase.client.TableState;
+import org.apache.hadoop.hbase.procedure2.ProcedureStateSerializer;
+import org.apache.hadoop.hbase.procedure2.ProcedureSuspendedException;
+import org.apache.hadoop.hbase.procedure2.ProcedureUtil;
+import org.apache.hadoop.hbase.regionserver.HRegionFileSystem;
+import org.apache.hadoop.hbase.util.CommonFSUtils;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
+import org.apache.hadoop.hbase.util.FSUtils;
+import org.apache.hadoop.hbase.util.RetryCounter;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.com.google.common.collect.Lists;
+
+import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProcedureProtos.RefreshMetaState;
+import 
org.apache.hadoop.hbase.shaded.protobuf.generated.MasterProcedureProtos.RefreshMetaStateData;
+
[email protected]
+public class RefreshMetaProcedure extends 
AbstractStateMachineTableProcedure<RefreshMetaState> {
+  private static final Logger LOG = 
LoggerFactory.getLogger(RefreshMetaProcedure.class);
+  private static final String HIDDEN_DIR_PATTERN = "^[._-].*";
+
+  private List<RegionInfo> currentRegions;
+  private List<RegionInfo> latestRegions;
+  private List<Mutation> pendingMutations;
+  private RetryCounter retryCounter;
+  private static final int MUTATION_BATCH_SIZE = 100;
+  private List<RegionInfo> newlyAddedRegions;
+  private List<TableName> deletedTables;
+
+  public RefreshMetaProcedure() {
+    super();
+  }
+
+  public RefreshMetaProcedure(MasterProcedureEnv env) {
+    super(env);
+  }
+
+  @Override
+  public TableName getTableName() {
+    return TableName.META_TABLE_NAME;
+  }
+
+  @Override
+  public TableOperationType getTableOperationType() {
+    return TableOperationType.EDIT;
+  }
+
+  @Override
+  protected Flow executeFromState(MasterProcedureEnv env, RefreshMetaState 
refreshMetaState) {
+    LOG.info("Executing RefreshMetaProcedure state: {}", refreshMetaState);
+
+    try {
+      return switch (refreshMetaState) {
+        case REFRESH_META_INIT -> executeInit(env);
+        case REFRESH_META_SCAN_STORAGE -> executeScanStorage(env);
+        case REFRESH_META_PREPARE -> executePrepare();
+        case REFRESH_META_APPLY -> executeApply(env);
+        case REFRESH_META_FOLLOWUP -> executeFollowup(env);
+        case REFRESH_META_FINISH -> executeFinish(env);
+        default -> throw new UnsupportedOperationException("Unhandled state: " 
+ refreshMetaState);
+      };
+    } catch (Exception ex) {
+      LOG.error("Error in RefreshMetaProcedure state {}", refreshMetaState, 
ex);
+      setFailure("RefreshMetaProcedure", ex);
+      return Flow.NO_MORE_STATE;
+    }
+  }
+
+  private Flow executeInit(MasterProcedureEnv env) throws IOException {
+    LOG.trace("Getting current regions from hbase:meta table");
+    try {
+      currentRegions = 
getCurrentRegions(env.getMasterServices().getConnection());
+      LOG.info("Found {} current regions in meta table", 
currentRegions.size());
+      setNextState(RefreshMetaState.REFRESH_META_SCAN_STORAGE);
+      return Flow.HAS_MORE_STATE;
+    } catch (IOException ioe) {
+      LOG.error("Failed to get current regions from meta table", ioe);
+      throw ioe;
+    }
+  }
+
+  private Flow executeScanStorage(MasterProcedureEnv env) throws IOException {
+    try {
+      latestRegions = 
scanBackingStorage(env.getMasterServices().getConnection());
+      LOG.info("Found {} regions in backing storage", latestRegions.size());
+      setNextState(RefreshMetaState.REFRESH_META_PREPARE);
+      return Flow.HAS_MORE_STATE;
+    } catch (IOException ioe) {
+      LOG.error("Failed to scan backing storage", ioe);
+      throw ioe;
+    }
+  }
+
+  private Flow executePrepare() throws IOException {
+    if (currentRegions == null || latestRegions == null) {
+      LOG.error(
+        "Can not execute update on null lists. " + "Meta Table Regions - {}, 
Storage Regions - {}",
+        currentRegions, latestRegions);
+      throw new IOException(
+        (currentRegions == null ? "current regions" : "latest regions") + " 
list is null");
+    }
+    LOG.info("Comparing regions. Current regions: {}, Latest regions: {}", 
currentRegions.size(),
+      latestRegions.size());
+
+    this.newlyAddedRegions = new ArrayList<>();
+    this.deletedTables = new ArrayList<>();
+
+    pendingMutations = prepareMutations(
+      currentRegions.stream()
+        .collect(Collectors.toMap(RegionInfo::getEncodedName, 
Function.identity())),
+      latestRegions.stream()
+        .collect(Collectors.toMap(RegionInfo::getEncodedName, 
Function.identity())));
+
+    if (pendingMutations.isEmpty()) {
+      LOG.info("RefreshMetaProcedure completed, No update needed.");
+      setNextState(RefreshMetaState.REFRESH_META_FINISH);
+    } else {
+      LOG.info("Prepared {} region mutations and {} tables for cleanup.", 
pendingMutations.size(),
+        deletedTables.size());
+      setNextState(RefreshMetaState.REFRESH_META_APPLY);
+    }
+    return Flow.HAS_MORE_STATE;
+  }
+
+  private Flow executeApply(MasterProcedureEnv env) throws 
ProcedureSuspendedException {
+    try {
+      if (pendingMutations != null && !pendingMutations.isEmpty()) {
+        applyMutations(env.getMasterServices().getConnection(), 
pendingMutations);
+        LOG.debug("RefreshMetaProcedure applied {} mutations to meta table",
+          pendingMutations.size());
+      }
+    } catch (IOException ioe) {
+      if (retryCounter == null) {
+        retryCounter = 
ProcedureUtil.createRetryCounter(env.getMasterConfiguration());
+      }
+      long backoff = retryCounter.getBackoffTimeAndIncrementAttempts();
+      LOG.warn("Failed to apply mutations to meta table, suspending for {} 
ms", backoff, ioe);
+      setTimeout(Math.toIntExact(backoff));
+      setState(WAITING_TIMEOUT);
+      skipPersistence();
+      throw new ProcedureSuspendedException();
+    }
+
+    if (
+      (this.newlyAddedRegions != null && !this.newlyAddedRegions.isEmpty())
+        || (this.deletedTables != null && !this.deletedTables.isEmpty())
+    ) {
+      setNextState(RefreshMetaState.REFRESH_META_FOLLOWUP);
+    } else {
+      LOG.info("RefreshMetaProcedure completed. No follow-up actions were 
required.");
+      setNextState(RefreshMetaState.REFRESH_META_FINISH);
+    }
+    return Flow.HAS_MORE_STATE;
+  }
+
+  private Flow executeFollowup(MasterProcedureEnv env) throws IOException {
+
+    LOG.info("Submitting assignment for new regions: {}", 
this.newlyAddedRegions);
+    
addChildProcedure(env.getAssignmentManager().createAssignProcedures(newlyAddedRegions));
+
+    for (TableName tableName : this.deletedTables) {
+      LOG.debug("Submitting deletion for empty table {}", tableName);
+      env.getMasterServices().getAssignmentManager().deleteTable(tableName);
+      
env.getMasterServices().getTableStateManager().setDeletedTable(tableName);
+      env.getMasterServices().getTableDescriptors().remove(tableName);
+    }
+    setNextState(RefreshMetaState.REFRESH_META_FINISH);
+    return Flow.HAS_MORE_STATE;
+  }
+
+  private Flow executeFinish(MasterProcedureEnv env) {
+    invalidateTableDescriptorCache(env);
+    LOG.info("RefreshMetaProcedure completed successfully. All follow-up 
actions finished.");
+    currentRegions = null;
+    latestRegions = null;
+    pendingMutations = null;
+    deletedTables = null;
+    newlyAddedRegions = null;
+    return Flow.NO_MORE_STATE;
+  }
+
+  private void invalidateTableDescriptorCache(MasterProcedureEnv env) {
+    LOG.debug("Invalidating the table descriptor cache to ensure new tables 
are discovered");
+    
env.getMasterServices().getTableDescriptors().invalidateTableDescriptorCache();
+  }
+
+  /**
+   * Prepares mutations by comparing the current regions in hbase:meta with 
the latest regions from
+   * backing storage. Also populates newlyAddedRegions and deletedTables lists 
for follow-up
+   * actions.
+   * @param currentMap Current regions from hbase:meta
+   * @param latestMap  Latest regions from backing storage
+   * @return List of mutations to apply to the meta table
+   * @throws IOException If there is an error creating mutations
+   */
+  private List<Mutation> prepareMutations(Map<String, RegionInfo> currentMap,
+    Map<String, RegionInfo> latestMap) throws IOException {
+    List<Mutation> mutations = new ArrayList<>();
+
+    for (String regionId : Stream.concat(currentMap.keySet().stream(), 
latestMap.keySet().stream())
+      .collect(Collectors.toSet())) {
+      RegionInfo currentRegion = currentMap.get(regionId);
+      RegionInfo latestRegion = latestMap.get(regionId);
+
+      if (latestRegion != null) {
+        if (currentRegion == null || hasBoundaryChanged(currentRegion, 
latestRegion)) {
+          mutations.add(MetaTableAccessor.makePutFromRegionInfo(latestRegion));
+          newlyAddedRegions.add(latestRegion);
+        }
+      } else {
+        mutations.add(MetaTableAccessor.makeDeleteFromRegionInfo(currentRegion,
+          EnvironmentEdgeManager.currentTime()));
+      }
+    }
+
+    if (!currentMap.isEmpty() || !latestMap.isEmpty()) {
+      Set<TableName> currentTables =
+        
currentMap.values().stream().map(RegionInfo::getTable).collect(Collectors.toSet());
+      Set<TableName> latestTables =
+        
latestMap.values().stream().map(RegionInfo::getTable).collect(Collectors.toSet());
+
+      Set<TableName> tablesToDeleteState = new HashSet<>(currentTables);
+      tablesToDeleteState.removeAll(latestTables);
+      if (!tablesToDeleteState.isEmpty()) {
+        LOG.warn(
+          "The following tables have no regions on storage and WILL BE REMOVED 
from the meta: {}",
+          tablesToDeleteState);
+        this.deletedTables.addAll(tablesToDeleteState);
+      }
+
+      Set<TableName> tablesToRestoreState = new HashSet<>(latestTables);
+      tablesToRestoreState.removeAll(currentTables);
+      if (!tablesToRestoreState.isEmpty()) {
+        LOG.info("Adding missing table:state entry for recovered tables: {}", 
tablesToRestoreState);
+        for (TableName tableName : tablesToRestoreState) {
+          TableState tableState = new TableState(tableName, 
TableState.State.ENABLED);
+          mutations.add(MetaTableAccessor.makePutFromTableState(tableState,
+            EnvironmentEdgeManager.currentTime()));
+        }
+      }
+    }
+    return mutations;
+  }
+
+  private void applyMutations(Connection connection, List<Mutation> mutations) 
throws IOException {
+    List<List<Mutation>> chunks = Lists.partition(mutations, 
MUTATION_BATCH_SIZE);
+
+    for (int i = 0; i < chunks.size(); i++) {
+      List<Mutation> chunk = chunks.get(i);
+
+      List<Put> puts =
+        chunk.stream().filter(m -> m instanceof Put).map(m -> (Put) 
m).collect(Collectors.toList());
+
+      List<Delete> deletes = chunk.stream().filter(m -> m instanceof 
Delete).map(m -> (Delete) m)
+        .collect(Collectors.toList());
+
+      if (!puts.isEmpty()) {
+        MetaTableAccessor.putsToMetaTable(connection, puts);
+      }
+      if (!deletes.isEmpty()) {
+        MetaTableAccessor.deleteFromMetaTable(connection, deletes);
+      }
+      LOG.debug("Successfully processed batch {}/{}", i + 1, chunks.size());
+    }
+  }
+
+  boolean hasBoundaryChanged(RegionInfo region1, RegionInfo region2) {
+    return !Arrays.equals(region1.getStartKey(), region2.getStartKey())
+      || !Arrays.equals(region1.getEndKey(), region2.getEndKey());
+  }
+
+  /**
+   * Scans the backing storage for all regions and returns a list of 
RegionInfo objects. This method
+   * scans the filesystem for region directories and reads their .regioninfo 
files.
+   * @param connection The HBase connection to use.
+   * @return List of RegionInfo objects found in the backing storage.
+   * @throws IOException If there is an error accessing the filesystem or 
reading region info files.
+   */
+  List<RegionInfo> scanBackingStorage(Connection connection) throws 
IOException {
+    List<RegionInfo> regions = new ArrayList<>();
+    Configuration conf = connection.getConfiguration();
+    FileSystem fs = FileSystem.get(conf);
+    Path rootDir = CommonFSUtils.getRootDir(conf);
+    Path dataDir = new Path(rootDir, HConstants.BASE_NAMESPACE_DIR);
+
+    LOG.info("Scanning backing storage under: {}", dataDir);
+
+    if (!fs.exists(dataDir)) {
+      LOG.warn("Data directory does not exist: {}", dataDir);
+      return regions;
+    }
+
+    FileStatus[] namespaceDirs =
+      fs.listStatus(dataDir, path -> 
!path.getName().matches(HIDDEN_DIR_PATTERN));
+    LOG.debug("Found {} namespace directories in data dir", 
Arrays.stream(namespaceDirs).toList());
+
+    for (FileStatus nsDir : namespaceDirs) {
+      String namespaceName = nsDir.getPath().getName();
+      if (NamespaceDescriptor.SYSTEM_NAMESPACE_NAME_STR.equals(namespaceName)) 
{
+        LOG.info("Skipping system namespace {}", namespaceName);
+        continue;
+      }
+      try {
+        List<RegionInfo> namespaceRegions = scanTablesInNamespace(fs, 
nsDir.getPath());
+        regions.addAll(namespaceRegions);
+        LOG.debug("Found {} regions in namespace {}", namespaceRegions.size(),
+          nsDir.getPath().getName());
+      } catch (IOException e) {
+        LOG.error("Failed to scan namespace directory: {}", nsDir.getPath(), 
e);
+      }
+    }
+    LOG.info("Scanned backing storage and found {} regions", regions.size());
+    return regions;
+  }
+
+  private List<RegionInfo> scanTablesInNamespace(FileSystem fs, Path 
namespacePath)
+    throws IOException {
+    LOG.debug("Scanning namespace {}", namespacePath.getName());
+    List<Path> tableDirs = FSUtils.getLocalTableDirs(fs, namespacePath);
+
+    return tableDirs.parallelStream().flatMap(tableDir -> {
+      try {
+        List<RegionInfo> tableRegions = scanRegionsInTable(fs, 
FSUtils.getRegionDirs(fs, tableDir));
+        LOG.debug("Found {} regions in table {} in namespace {}", 
tableRegions.size(),
+          tableDir.getName(), namespacePath.getName());
+        return tableRegions.stream();
+      } catch (IOException e) {
+        LOG.warn("Failed to scan table directory: {} for namespace {}", 
tableDir,
+          namespacePath.getName(), e);
+        return Stream.empty();
+      }
+    }).toList();
+  }
+
+  private List<RegionInfo> scanRegionsInTable(FileSystem fs, List<Path> 
regionDirs)
+    throws IOException {
+    return regionDirs.stream().map(regionDir -> {
+      String encodedRegionName = regionDir.getName();
+      try {
+        Path regionInfoPath = new Path(regionDir, 
HRegionFileSystem.REGION_INFO_FILE);
+        if (fs.exists(regionInfoPath)) {
+          RegionInfo ri = readRegionInfo(fs, regionInfoPath);
+          if (ri != null && isValidRegionInfo(ri, encodedRegionName)) {
+            LOG.debug("Found region: {} -> {}", encodedRegionName, 
ri.getRegionNameAsString());
+            return ri;
+          } else {
+            LOG.warn("Invalid RegionInfo in file: {}", regionInfoPath);
+          }
+        } else {
+          LOG.debug("No .regioninfo file found in region directory: {}", 
regionDir);
+        }
+      } catch (Exception e) {
+        LOG.warn("Failed to read region info from directory: {}", 
encodedRegionName, e);
+      }
+      return null;
+    }).filter(Objects::nonNull).collect(Collectors.toList());
+  }
+
+  private boolean isValidRegionInfo(RegionInfo regionInfo, String 
expectedEncodedName) {
+    if (!expectedEncodedName.equals(regionInfo.getEncodedName())) {
+      LOG.warn("RegionInfo encoded name mismatch: directory={}, 
regioninfo={}", expectedEncodedName,
+        regionInfo.getEncodedName());
+      return false;
+    }
+    return true;
+  }
+
+  private RegionInfo readRegionInfo(FileSystem fs, Path regionInfoPath) {
+    try (FSDataInputStream inputStream = fs.open(regionInfoPath);
+      DataInputStream dataInputStream = new DataInputStream(inputStream)) {
+      return RegionInfo.parseFrom(dataInputStream);
+    } catch (Exception e) {
+      LOG.warn("Failed to parse .regioninfo file: {}", regionInfoPath, e);
+      return null;
+    }
+  }
+
+  /**
+   * Retrieves the current regions from the hbase:meta table.
+   * @param connection The HBase connection to use.
+   * @return List of RegionInfo objects representing the current regions in 
meta.
+   * @throws IOException If there is an error accessing the meta table.
+   */
+  List<RegionInfo> getCurrentRegions(Connection connection) throws IOException 
{
+    LOG.info("Getting all regions from meta table");
+    return MetaTableAccessor.getAllRegions(connection, true);
+  }
+
+  @Override
+  protected synchronized boolean setTimeoutFailure(MasterProcedureEnv env) {
+    setState(
+      
org.apache.hadoop.hbase.shaded.protobuf.generated.ProcedureProtos.ProcedureState.RUNNABLE);
+    env.getProcedureScheduler().addFront(this);
+    return false;
+  }
+
+  @Override
+  protected void rollbackState(MasterProcedureEnv env, RefreshMetaState 
refreshMetaState)
+    throws IOException, InterruptedException {
+    // No specific rollback needed as it is generally safe to re-run the 
procedure.
+    LOG.trace("Rollback not implemented for RefreshMetaProcedure state: {}", 
refreshMetaState);
+  }
+
+  @Override
+  protected RefreshMetaState getState(int stateId) {
+    return RefreshMetaState.forNumber(stateId);
+  }
+
+  @Override
+  protected int getStateId(RefreshMetaState refreshMetaState) {
+    return refreshMetaState.getNumber();
+  }
+
+  @Override
+  protected RefreshMetaState getInitialState() {
+    return RefreshMetaState.REFRESH_META_INIT;
+  }
+
+  @Override
+  protected void serializeStateData(ProcedureStateSerializer serializer) 
throws IOException {
+    // For now, we'll use a simple approach since we do not need to store any 
state data
+    RefreshMetaStateData.Builder builder = RefreshMetaStateData.newBuilder();
+    serializer.serialize(builder.build());
+  }
+
+  @Override
+  protected void deserializeStateData(ProcedureStateSerializer serializer) 
throws IOException {
+    // For now, we'll use a simple approach since we do not need to store any 
state data
+    serializer.deserialize(RefreshMetaStateData.class);
+  }
+}
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/access/ReadOnlyController.java
 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/access/ReadOnlyController.java
index 13f458299b9..5b7ab67df0b 100644
--- 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/access/ReadOnlyController.java
+++ 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/access/ReadOnlyController.java
@@ -109,6 +109,9 @@ public class ReadOnlyController implements 
MasterCoprocessor, RegionCoprocessor,
   @Override
   public void preDelete(ObserverContext<? extends 
RegionCoprocessorEnvironment> c, Delete delete,
     WALEdit edit) throws IOException {
+    if (c.getEnvironment().getRegionInfo().getTable().isSystemTable()) {
+      return;
+    }
     internalReadOnlyGuard();
   }
 
@@ -166,7 +169,9 @@ public class ReadOnlyController implements 
MasterCoprocessor, RegionCoprocessor,
   public boolean preCheckAndDelete(ObserverContext<? extends 
RegionCoprocessorEnvironment> c,
     byte[] row, byte[] family, byte[] qualifier, CompareOperator op, 
ByteArrayComparable comparator,
     Delete delete, boolean result) throws IOException {
-    internalReadOnlyGuard();
+    if (!c.getEnvironment().getRegionInfo().getTable().isSystemTable()) {
+      internalReadOnlyGuard();
+    }
     return RegionObserver.super.preCheckAndDelete(c, row, family, qualifier, 
op, comparator, delete,
       result);
   }
@@ -174,7 +179,9 @@ public class ReadOnlyController implements 
MasterCoprocessor, RegionCoprocessor,
   @Override
   public boolean preCheckAndDelete(ObserverContext<? extends 
RegionCoprocessorEnvironment> c,
     byte[] row, Filter filter, Delete delete, boolean result) throws 
IOException {
-    internalReadOnlyGuard();
+    if (!c.getEnvironment().getRegionInfo().getTable().isSystemTable()) {
+      internalReadOnlyGuard();
+    }
     return RegionObserver.super.preCheckAndDelete(c, row, filter, delete, 
result);
   }
 
@@ -183,7 +190,9 @@ public class ReadOnlyController implements 
MasterCoprocessor, RegionCoprocessor,
     ObserverContext<? extends RegionCoprocessorEnvironment> c, byte[] row, 
byte[] family,
     byte[] qualifier, CompareOperator op, ByteArrayComparable comparator, 
Delete delete,
     boolean result) throws IOException {
-    internalReadOnlyGuard();
+    if (!c.getEnvironment().getRegionInfo().getTable().isSystemTable()) {
+      internalReadOnlyGuard();
+    }
     return RegionObserver.super.preCheckAndDeleteAfterRowLock(c, row, family, 
qualifier, op,
       comparator, delete, result);
   }
@@ -192,7 +201,9 @@ public class ReadOnlyController implements 
MasterCoprocessor, RegionCoprocessor,
   public boolean preCheckAndDeleteAfterRowLock(
     ObserverContext<? extends RegionCoprocessorEnvironment> c, byte[] row, 
Filter filter,
     Delete delete, boolean result) throws IOException {
-    internalReadOnlyGuard();
+    if (!c.getEnvironment().getRegionInfo().getTable().isSystemTable()) {
+      internalReadOnlyGuard();
+    }
     return RegionObserver.super.preCheckAndDeleteAfterRowLock(c, row, filter, 
delete, result);
   }
 
diff --git 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/util/FSTableDescriptors.java
 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/util/FSTableDescriptors.java
index 75bf721ef41..b32fad50f0f 100644
--- 
a/hbase-server/src/main/java/org/apache/hadoop/hbase/util/FSTableDescriptors.java
+++ 
b/hbase-server/src/main/java/org/apache/hadoop/hbase/util/FSTableDescriptors.java
@@ -706,4 +706,14 @@ public class FSTableDescriptors implements 
TableDescriptors {
     }
     return writeTableDescriptor(fs, htd, tableDir, 
opt.map(Pair::getFirst).orElse(null)) != null;
   }
+
+  /**
+   * Invalidates the table descriptor cache.
+   */
+  @Override
+  public void invalidateTableDescriptorCache() {
+    LOG.info("Invalidating table descriptor cache.");
+    this.fsvisited = false;
+    this.cache.clear();
+  }
 }
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedure.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedure.java
new file mode 100644
index 00000000000..e419d1df6ad
--- /dev/null
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedure.java
@@ -0,0 +1,121 @@
+/*
+ * 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.master.procedure;
+
+import static 
org.apache.hadoop.hbase.procedure2.ProcedureTestingUtility.assertProcNotFailed;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.RegionInfo;
+import org.apache.hadoop.hbase.client.RegionInfoBuilder;
+import org.apache.hadoop.hbase.procedure2.ProcedureExecutor;
+import org.apache.hadoop.hbase.procedure2.ProcedureTestingUtility;
+import org.apache.hadoop.hbase.testclassification.MasterTests;
+import org.apache.hadoop.hbase.testclassification.MediumTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+@Category({ MasterTests.class, MediumTests.class })
+public class TestRefreshMetaProcedure {
+
+  @ClassRule
+  public static final HBaseClassTestRule CLASS_RULE =
+    HBaseClassTestRule.forClass(TestRefreshMetaProcedure.class);
+
+  private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
+  private ProcedureExecutor<MasterProcedureEnv> procExecutor;
+  List<RegionInfo> activeRegions;
+  TableName tableName = TableName.valueOf("testRefreshMeta");
+
+  @Before
+  public void setup() throws Exception {
+    TEST_UTIL.getConfiguration().set("USE_META_REPLICAS", "false");
+    TEST_UTIL.startMiniCluster();
+    procExecutor = 
TEST_UTIL.getHBaseCluster().getMaster().getMasterProcedureExecutor();
+    byte[][] splitKeys =
+      new byte[][] { Bytes.toBytes("split1"), Bytes.toBytes("split2"), 
Bytes.toBytes("split3") };
+    TEST_UTIL.createTable(tableName, Bytes.toBytes("cf"), splitKeys);
+    TEST_UTIL.waitTableAvailable(tableName);
+    TEST_UTIL.getAdmin().flush(tableName);
+    activeRegions = TEST_UTIL.getAdmin().getRegions(tableName);
+    assertFalse(activeRegions.isEmpty());
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    TEST_UTIL.shutdownMiniCluster();
+  }
+
+  @Test
+  public void testRefreshMetaProcedureExecutesSuccessfully() {
+    RefreshMetaProcedure procedure = new 
RefreshMetaProcedure(procExecutor.getEnvironment());
+    long procId = procExecutor.submitProcedure(procedure);
+    ProcedureTestingUtility.waitProcedure(procExecutor, procId);
+    assertProcNotFailed(procExecutor.getResult(procId));
+  }
+
+  @Test
+  public void testGetCurrentRegions() throws Exception {
+    RefreshMetaProcedure procedure = new 
RefreshMetaProcedure(procExecutor.getEnvironment());
+    List<RegionInfo> regions = 
procedure.getCurrentRegions(TEST_UTIL.getConnection());
+    assertFalse("Should have found regions in meta", regions.isEmpty());
+    assertTrue("Should include test table region",
+      regions.stream().anyMatch(r -> 
r.getTable().getNameAsString().equals("testRefreshMeta")));
+  }
+
+  @Test
+  public void testScanBackingStorage() throws Exception {
+    RefreshMetaProcedure procedure = new 
RefreshMetaProcedure(procExecutor.getEnvironment());
+
+    List<RegionInfo> fsRegions = 
procedure.scanBackingStorage(TEST_UTIL.getConnection());
+
+    assertTrue("All regions from meta should be found in the storage",
+      activeRegions.stream().allMatch(reg -> fsRegions.stream()
+        .anyMatch(r -> 
r.getRegionNameAsString().equals(reg.getRegionNameAsString()))));
+  }
+
+  @Test
+  public void testHasBoundaryChanged() throws Exception {
+    RefreshMetaProcedure procedure = new 
RefreshMetaProcedure(procExecutor.getEnvironment());
+    RegionInfo region1 = RegionInfoBuilder.newBuilder(tableName)
+      
.setStartKey(Bytes.toBytes("start1")).setEndKey(Bytes.toBytes("end1")).build();
+
+    RegionInfo region2 = RegionInfoBuilder.newBuilder(tableName)
+      
.setStartKey(Bytes.toBytes("start2")).setEndKey(Bytes.toBytes("end1")).build();
+
+    RegionInfo region3 = RegionInfoBuilder.newBuilder(tableName)
+      
.setStartKey(Bytes.toBytes("start1")).setEndKey(Bytes.toBytes("end2")).build();
+
+    assertTrue("Different start keys should have been detected",
+      procedure.hasBoundaryChanged(region1, region2));
+
+    assertTrue("Different end keys should have been detected",
+      procedure.hasBoundaryChanged(region1, region3));
+
+    assertFalse("Identical boundaries should not have been identified",
+      procedure.hasBoundaryChanged(region1, region1));
+  }
+}
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedureIntegration.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedureIntegration.java
new file mode 100644
index 00000000000..917c12c6513
--- /dev/null
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/procedure/TestRefreshMetaProcedureIntegration.java
@@ -0,0 +1,285 @@
+/*
+ * 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.master.procedure;
+
+import static 
org.apache.hadoop.hbase.procedure2.ProcedureTestingUtility.assertProcNotFailed;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.hadoop.fs.FSDataOutputStream;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.MetaTableAccessor;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.Admin;
+import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
+import org.apache.hadoop.hbase.client.Put;
+import org.apache.hadoop.hbase.client.RegionInfo;
+import org.apache.hadoop.hbase.client.RegionInfoBuilder;
+import org.apache.hadoop.hbase.client.Table;
+import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
+import org.apache.hadoop.hbase.client.TableState;
+import org.apache.hadoop.hbase.coprocessor.CoprocessorHost;
+import org.apache.hadoop.hbase.master.HMaster;
+import org.apache.hadoop.hbase.procedure2.ProcedureExecutor;
+import org.apache.hadoop.hbase.regionserver.HRegionFileSystem;
+import org.apache.hadoop.hbase.regionserver.HRegionServer;
+import org.apache.hadoop.hbase.security.access.ReadOnlyController;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.testclassification.MasterTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.hadoop.hbase.util.CommonFSUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+@Category({ MasterTests.class, LargeTests.class })
+public class TestRefreshMetaProcedureIntegration {
+
+  @ClassRule
+  public static final HBaseClassTestRule CLASS_RULE =
+    HBaseClassTestRule.forClass(TestRefreshMetaProcedureIntegration.class);
+
+  private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
+  private Admin admin;
+  private ProcedureExecutor<MasterProcedureEnv> procExecutor;
+  private HMaster master;
+  private HRegionServer regionServer;
+
+  @Before
+  public void setup() throws Exception {
+    // Configure the cluster with ReadOnlyController
+    
TEST_UTIL.getConfiguration().set(CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY,
+      ReadOnlyController.class.getName());
+    
TEST_UTIL.getConfiguration().set(CoprocessorHost.REGIONSERVER_COPROCESSOR_CONF_KEY,
+      ReadOnlyController.class.getName());
+    
TEST_UTIL.getConfiguration().set(CoprocessorHost.REGION_COPROCESSOR_CONF_KEY,
+      ReadOnlyController.class.getName());
+
+    // Start in active mode
+    
TEST_UTIL.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY,
 false);
+
+    TEST_UTIL.startMiniCluster();
+    admin = TEST_UTIL.getAdmin();
+    procExecutor = 
TEST_UTIL.getHBaseCluster().getMaster().getMasterProcedureExecutor();
+    master = TEST_UTIL.getHBaseCluster().getMaster();
+    regionServer = 
TEST_UTIL.getHBaseCluster().getRegionServerThreads().get(0).getRegionServer();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (admin != null) {
+      admin.close();
+    }
+    TEST_UTIL.shutdownMiniCluster();
+  }
+
+  @Test
+  public void testRestoreMissingRegionInMeta() throws Exception {
+
+    TableName tableName = TableName.valueOf("replicaTestTable");
+
+    createTableWithData(tableName);
+
+    List<RegionInfo> activeRegions = admin.getRegions(tableName);
+    assertTrue("Should have at least 2 regions after split", 
activeRegions.size() >= 2);
+
+    Table metaTable = 
TEST_UTIL.getConnection().getTable(TableName.META_TABLE_NAME);
+    RegionInfo regionToRemove = activeRegions.get(0);
+    admin.unassign(regionToRemove.getRegionName(), false);
+    Thread.sleep(1000);
+
+    org.apache.hadoop.hbase.client.Delete delete =
+      new 
org.apache.hadoop.hbase.client.Delete(regionToRemove.getRegionName());
+    metaTable.delete(delete);
+    metaTable.close();
+
+    List<RegionInfo> regionsAfterDrift = admin.getRegions(tableName);
+    assertEquals("Should have one less region in meta after simulating drift",
+      activeRegions.size() - 1, regionsAfterDrift.size());
+
+    setReadOnlyMode(true);
+
+    boolean writeBlocked = false;
+    try {
+      Table readOnlyTable = TEST_UTIL.getConnection().getTable(tableName);
+      Put testPut = new Put(Bytes.toBytes("test_readonly"));
+      testPut.addColumn(Bytes.toBytes("cf1"), Bytes.toBytes("qual"), 
Bytes.toBytes("should_fail"));
+      readOnlyTable.put(testPut);
+      readOnlyTable.close();
+    } catch (Exception e) {
+      if (e.getMessage().contains("Operation not allowed in Read-Only Mode")) {
+        writeBlocked = true;
+      }
+    }
+    assertTrue("Write operations should be blocked in read-only mode", 
writeBlocked);
+
+    Long procId = admin.refreshMeta();
+
+    waitForProcedureCompletion(procId);
+
+    List<RegionInfo> regionsAfterRefresh = admin.getRegions(tableName);
+    assertEquals("Missing regions should be restored by refresh_meta", 
activeRegions.size(),
+      regionsAfterRefresh.size());
+
+    boolean regionRestored = regionsAfterRefresh.stream()
+      .anyMatch(r -> 
r.getRegionNameAsString().equals(regionToRemove.getRegionNameAsString()));
+    assertTrue("Missing region should be restored by refresh_meta", 
regionRestored);
+
+    setReadOnlyMode(false);
+
+    Table activeTable = TEST_UTIL.getConnection().getTable(tableName);
+    Put testPut = new Put(Bytes.toBytes("test_active_again"));
+    testPut.addColumn(Bytes.toBytes("cf1"), Bytes.toBytes("qual"),
+      Bytes.toBytes("active_mode_again"));
+    activeTable.put(testPut);
+    activeTable.close();
+  }
+
+  @Test
+  public void testPhantomTableCleanup() throws Exception {
+    TableName table1 = TableName.valueOf("table1");
+    TableName phantomTable = TableName.valueOf("phantomTable");
+    createTableWithData(table1);
+    createTableWithData(phantomTable);
+
+    assertTrue("Table1 should have multiple regions", 
admin.getRegions(table1).size() >= 2);
+    assertTrue("phantomTable should have multiple regions",
+      admin.getRegions(phantomTable).size() >= 2);
+
+    deleteTableFromFilesystem(phantomTable);
+    List<TableName> tablesBeforeRefresh = 
Arrays.asList(admin.listTableNames());
+    assertTrue("phantomTable should still be listed before refresh_meta",
+      tablesBeforeRefresh.contains(phantomTable));
+    assertTrue("Table1 should still be listed", 
tablesBeforeRefresh.contains(table1));
+
+    setReadOnlyMode(true);
+    Long procId = admin.refreshMeta();
+    waitForProcedureCompletion(procId);
+
+    List<TableName> tablesAfterRefresh = Arrays.asList(admin.listTableNames());
+
+    assertFalse("phantomTable should be removed after refresh_meta",
+      tablesAfterRefresh.contains(phantomTable));
+    assertTrue("Table1 should still be listed", 
tablesAfterRefresh.contains(table1));
+    assertTrue("phantomTable should have no regions after refresh_meta",
+      admin.getRegions(phantomTable).isEmpty());
+    setReadOnlyMode(false);
+  }
+
+  @Test
+  public void testRestoreTableStateForOrphanRegions() throws Exception {
+    TableName tableName = TableName.valueOf("t1");
+    createTableInFilesystem(tableName);
+
+    assertEquals("No tables should exist", 0,
+      Stream.of(admin.listTableNames()).filter(tn -> 
tn.equals(tableName)).count());
+
+    setReadOnlyMode(true);
+    Long procId = admin.refreshMeta();
+    waitForProcedureCompletion(procId);
+
+    TableState tableState = 
MetaTableAccessor.getTableState(admin.getConnection(), tableName);
+    assert tableState != null;
+    assertEquals("Table state should be ENABLED", TableState.State.ENABLED, 
tableState.getState());
+    assertEquals("The list should show the new table from the FS", 1,
+      Stream.of(admin.listTableNames()).filter(tn -> 
tn.equals(tableName)).count());
+    assertFalse("Should have at least 1 region", 
admin.getRegions(tableName).isEmpty());
+    setReadOnlyMode(false);
+  }
+
+  private void createTableInFilesystem(TableName tableName) throws IOException 
{
+    FileSystem fs = TEST_UTIL.getTestFileSystem();
+    Path rootDir = CommonFSUtils.getRootDir(TEST_UTIL.getConfiguration());
+    Path tableDir = CommonFSUtils.getTableDir(rootDir, tableName);
+    fs.mkdirs(tableDir);
+
+    TableDescriptorBuilder builder = 
TableDescriptorBuilder.newBuilder(tableName);
+    TEST_UTIL.getHBaseCluster().getMaster().getTableDescriptors()
+      
.update(builder.setColumnFamily(ColumnFamilyDescriptorBuilder.of("cf1")).build(),
 false);
+
+    Path regionDir = new Path(tableDir, "dab6d1e1c88787c13b97647f11b2c907");
+    Path regionInfoFile = new Path(regionDir, 
HRegionFileSystem.REGION_INFO_FILE);
+    fs.mkdirs(regionDir);
+
+    RegionInfo regionInfo = 
RegionInfoBuilder.newBuilder(tableName).setStartKey(new byte[0])
+      .setEndKey(new byte[0]).setRegionId(1757100253228L).build();
+    byte[] regionInfoContent = RegionInfo.toDelimitedByteArray(regionInfo);
+    try (FSDataOutputStream out = fs.create(regionInfoFile, true)) {
+      out.write(regionInfoContent);
+    }
+  }
+
+  private void deleteTableFromFilesystem(TableName tableName) throws 
IOException {
+    FileSystem fs = TEST_UTIL.getTestFileSystem();
+    Path rootDir = CommonFSUtils.getRootDir(TEST_UTIL.getConfiguration());
+    Path tableDir = CommonFSUtils.getTableDir(rootDir, tableName);
+    if (fs.exists(tableDir)) {
+      fs.delete(tableDir, true);
+    }
+  }
+
+  private void createTableWithData(TableName tableName) throws Exception {
+    TableDescriptorBuilder builder = 
TableDescriptorBuilder.newBuilder(tableName);
+    builder.setColumnFamily(ColumnFamilyDescriptorBuilder.of("cf1"));
+    byte[] splitKeyBytes = Bytes.toBytes("split_key");
+    admin.createTable(builder.build(), new byte[][] { splitKeyBytes });
+    TEST_UTIL.waitTableAvailable(tableName);
+    try (Table table = TEST_UTIL.getConnection().getTable(tableName)) {
+      for (int i = 0; i < 100; i++) {
+        Put put = new Put(Bytes.toBytes("row_" + String.format("%05d", i)));
+        put.addColumn(Bytes.toBytes("cf1"), Bytes.toBytes("qual"), 
Bytes.toBytes("value_" + i));
+        table.put(put);
+      }
+    }
+    admin.flush(tableName);
+  }
+
+  private void waitForProcedureCompletion(Long procId) {
+    assertTrue("Procedure ID should be positive", procId > 0);
+    TEST_UTIL.waitFor(1000, () -> {
+      try {
+        return procExecutor.isFinished(procId);
+      } catch (Exception e) {
+        return false;
+      }
+    });
+    assertProcNotFailed(procExecutor.getResult(procId));
+  }
+
+  private void setReadOnlyMode(boolean isReadOnly) {
+    
TEST_UTIL.getConfiguration().setBoolean(HConstants.HBASE_GLOBAL_READONLY_ENABLED_KEY,
+      isReadOnly);
+    notifyConfigurationObservers();
+  }
+
+  private void notifyConfigurationObservers() {
+    
master.getConfigurationManager().notifyAllObservers(TEST_UTIL.getConfiguration());
+    
regionServer.getConfigurationManager().notifyAllObservers(TEST_UTIL.getConfiguration());
+  }
+}
diff --git 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/rsgroup/VerifyingRSGroupAdmin.java
 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/rsgroup/VerifyingRSGroupAdmin.java
index 35c868413e1..5bdd97419e6 100644
--- 
a/hbase-server/src/test/java/org/apache/hadoop/hbase/rsgroup/VerifyingRSGroupAdmin.java
+++ 
b/hbase-server/src/test/java/org/apache/hadoop/hbase/rsgroup/VerifyingRSGroupAdmin.java
@@ -989,4 +989,9 @@ public class VerifyingRSGroupAdmin implements Admin, 
Closeable {
   public boolean isReplicationPeerModificationEnabled() throws IOException {
     return admin.isReplicationPeerModificationEnabled();
   }
+
+  @Override
+  public Long refreshMeta() throws IOException {
+    return admin.refreshMeta();
+  }
 }
diff --git a/hbase-shell/src/main/ruby/hbase/admin.rb 
b/hbase-shell/src/main/ruby/hbase/admin.rb
index 5ceaf2a08c7..442628fa497 100644
--- a/hbase-shell/src/main/ruby/hbase/admin.rb
+++ b/hbase-shell/src/main/ruby/hbase/admin.rb
@@ -1917,6 +1917,12 @@ module Hbase
     def list_tables_by_state(isEnabled)
       @admin.listTableNamesByState(isEnabled).map(&:getNameAsString)
     end
+
+    
#----------------------------------------------------------------------------------------------
+    # Refresh hbase:meta table by syncing with the backing storage
+    def refresh_meta()
+      @admin.refreshMeta()
+    end
   end
   # rubocop:enable Metrics/ClassLength
 end
diff --git a/hbase-shell/src/main/ruby/shell.rb 
b/hbase-shell/src/main/ruby/shell.rb
index 46b38dd96b8..a62076856eb 100644
--- a/hbase-shell/src/main/ruby/shell.rb
+++ b/hbase-shell/src/main/ruby/shell.rb
@@ -495,6 +495,7 @@ Shell.load_command_group(
     decommission_regionservers
     recommission_regionserver
     truncate_region
+    refresh_meta
   ],
   # TODO: remove older hlog_roll command
   aliases: {
diff --git a/hbase-shell/src/main/ruby/shell/commands/refresh_meta.rb 
b/hbase-shell/src/main/ruby/shell/commands/refresh_meta.rb
new file mode 100644
index 00000000000..8c5acb49dc5
--- /dev/null
+++ b/hbase-shell/src/main/ruby/shell/commands/refresh_meta.rb
@@ -0,0 +1,43 @@
+#
+# 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.
+#
+
+module Shell
+  module Commands
+    class RefreshMeta < Command
+      def help
+        <<-EOF
+Refresh the hbase:meta table by syncing with backing storage.
+This command is used in Read Replica clusters to pick up new
+tables and regions from the shared storage.
+Examples:
+
+  hbase> refresh_meta
+
+The command returns a procedure ID that can be used to track the progress
+of the meta table refresh operation.
+EOF
+      end
+
+      def command
+        proc_id = admin.refresh_meta
+        formatter.row(["Refresh meta procedure submitted. Procedure ID: 
#{proc_id}"])
+        proc_id
+      end
+    end
+  end
+end
diff --git 
a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift2/client/ThriftAdmin.java
 
b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift2/client/ThriftAdmin.java
index 0eff84bba7c..58eb9b4edee 100644
--- 
a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift2/client/ThriftAdmin.java
+++ 
b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift2/client/ThriftAdmin.java
@@ -1355,6 +1355,11 @@ public class ThriftAdmin implements Admin {
     throw new NotImplementedException("getCachedFilesList not supported in 
ThriftAdmin");
   }
 
+  @Override
+  public Long refreshMeta() throws IOException {
+    throw new NotImplementedException("refreshMeta not supported in 
ThriftAdmin");
+  }
+
   @Override
   public boolean replicationPeerModificationSwitch(boolean on, boolean 
drainProcedures)
     throws IOException {

Reply via email to