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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7b19b7eaa [Subtask]: Optimizations and troubleshooting for the 
Master-Slave mode.  (#4174)
7b19b7eaa is described below

commit 7b19b7eaaa6f5ddd809d75886e15916b7ab57126
Author: can <[email protected]>
AuthorDate: Thu Apr 16 10:09:54 2026 +0800

    [Subtask]: Optimizations and troubleshooting for the Master-Slave mode.  
(#4174)
    
    * [Subtask]: Fixed several bugs in master-slave mode. #4171
    
    * [Subtask]: Fixed several bugs in master-slave mode. #4171
    
    * [Subtask]: Optimized based on CR feedback. #4171
    
    ---------
    
    Co-authored-by: wardli <[email protected]>
---
 .../apache/amoro/server/AmoroManagementConf.java   |  18 +-
 .../apache/amoro/server/AmoroServiceContainer.java |  10 +-
 .../org/apache/amoro/server/AmsAssignService.java  | 218 +++++++++++++++++----
 .../amoro/server/DefaultOptimizingService.java     |   3 +-
 .../ha/DataBaseHighAvailabilityContainer.java      |   6 +-
 .../server/ha/ZkHighAvailabilityContainer.java     |  25 ++-
 .../amoro/server/table/AbstractTableMetrics.java   |  12 +-
 .../amoro/server/table/DefaultTableService.java    | 206 ++++++++++++-------
 .../apache/amoro/server/TestAmsAssignService.java  |  68 ++++++-
 .../server/TestHighAvailabilityContainer.java      |  20 +-
 .../server/ha/TestZkHighAvailabilityContainer.java |  20 +-
 .../common/AbstractOptimizerOperator.java          | 143 ++++++++------
 .../amoro/optimizer/common/AmsNodeManager.java     |  53 +++--
 .../amoro/optimizer/common/OptimizerExecutor.java  |  84 +++++++-
 .../amoro/optimizer/common/OptimizerToucher.java   |  34 +++-
 .../optimizer/common/ThriftAmsNodeManager.java     |  15 +-
 docs/configuration/ams-config.md                   |   2 +-
 17 files changed, 699 insertions(+), 238 deletions(-)

diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java
index 98331ad33..9ac6fb08e 100644
--- a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java
+++ b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java
@@ -107,15 +107,6 @@ public class AmoroManagementConf {
           .defaultValue("")
           .withDescription("Optional LDAP bind password used when querying 
role-mapping groups.");
 
-  /** Enable master & slave mode, which supports horizontal scaling of AMS. */
-  public static final ConfigOption<Boolean> USE_MASTER_SLAVE_MODE =
-      ConfigOptions.key("use-master-slave-mode")
-          .booleanType()
-          .defaultValue(false)
-          .withDescription(
-              "This setting controls whether to enable the AMS horizontal 
scaling feature, "
-                  + "which is currently under development and testing.");
-
   public static final ConfigOption<Duration> 
CATALOG_META_CACHE_EXPIRATION_INTERVAL =
       ConfigOptions.key("catalog-meta-cache.expiration-interval")
           .durationType()
@@ -340,6 +331,15 @@ public class AmoroManagementConf {
           .defaultValue(java.time.Duration.ofSeconds(30))
           .withDescription("TTL of HA lease.");
 
+  /** Enable master & slave mode, which supports horizontal scaling of AMS. */
+  public static final ConfigOption<Boolean> HA_USE_MASTER_SLAVE_MODE =
+      ConfigOptions.key("ha.use-master-slave-mode")
+          .booleanType()
+          .defaultValue(false)
+          .withDescription(
+              "This setting controls whether to enable the AMS horizontal 
scaling feature, "
+                  + "which is currently under development and testing.");
+
   public static final ConfigOption<Integer> HA_BUCKET_ID_TOTAL_COUNT =
       ConfigOptions.key("ha.bucket-id.total-count")
           .intType()
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroServiceContainer.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroServiceContainer.java
index 768dab89f..0be364523 100644
--- a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroServiceContainer.java
+++ b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroServiceContainer.java
@@ -18,7 +18,7 @@
 
 package org.apache.amoro.server;
 
-import static 
org.apache.amoro.server.AmoroManagementConf.USE_MASTER_SLAVE_MODE;
+import static 
org.apache.amoro.server.AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE;
 
 import io.javalin.Javalin;
 import io.javalin.http.Context;
@@ -373,7 +373,7 @@ public class AmoroServiceContainer {
   private void initConfig() throws Exception {
     LOG.info("initializing configurations...");
     new ConfigurationHelper().init();
-    IS_MASTER_SLAVE_MODE = serviceConfig.getBoolean(USE_MASTER_SLAVE_MODE);
+    IS_MASTER_SLAVE_MODE = serviceConfig.getBoolean(HA_USE_MASTER_SLAVE_MODE);
   }
 
   public Configurations getServiceConfig() {
@@ -667,6 +667,12 @@ public class AmoroServiceContainer {
           containerProperties.putIfAbsent(
               OptimizerProperties.AMS_OPTIMIZER_URI,
               AmsUtil.getAMSThriftAddress(serviceConfig, 
Constants.THRIFT_OPTIMIZING_SERVICE_NAME));
+          // When master-slave mode is enabled, automatically inject the flag 
into container
+          // properties to ensure the -msm argument is correctly passed when 
starting optimizers.
+          if 
(serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE)) {
+            containerProperties.putIfAbsent(
+                OptimizerProperties.OPTIMIZER_MASTER_SLAVE_MODE_ENABLED, 
"true");
+          }
           // put addition system properties
           container.setProperties(containerProperties);
           containerList.add(container);
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/AmsAssignService.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/AmsAssignService.java
index 186ba1bcf..018be60a7 100644
--- a/amoro-ams/src/main/java/org/apache/amoro/server/AmsAssignService.java
+++ b/amoro-ams/src/main/java/org/apache/amoro/server/AmsAssignService.java
@@ -97,7 +97,7 @@ public class AmsAssignService {
    * Start the assignment service. Only works in master-slave mode and when 
current node is leader.
    */
   public void start() {
-    if (!serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE)) {
+    if 
(!serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE)) {
       LOG.info("Master-slave mode is not enabled, skip starting bucket 
assignment service");
       return;
     }
@@ -151,7 +151,20 @@ public class AmsAssignService {
           detectNodeChanges(aliveNodes, currentAssignments, aliveNodeMap, 
normalized.assignedNodes);
 
       if (!change.needReassign()) {
-        refreshLastUpdateTime(aliveNodes);
+        // Even if no node changes, check if current assignments are balanced.
+        // This handles cases where previous rebalancing was incomplete or
+        // assignments became uneven due to historical reasons.
+        if (isUnbalanced(aliveNodes, normalized.assignments)) {
+          LOG.info(
+              "No node changes detected, but current assignments are 
unbalanced. Performing rebalance...");
+          List<String> allBuckets = generateBucketIds();
+          Map<AmsServerInfo, List<String>> newAssignments =
+              buildNewAssignments(aliveNodes, new HashSet<>(), 
normalized.assignments);
+          rebalanceExistingAssignments(aliveNodes, allBuckets, newAssignments);
+          persistAssignments(newAssignments);
+        } else {
+          refreshLastUpdateTime(aliveNodes);
+        }
         return;
       }
 
@@ -175,8 +188,92 @@ public class AmsAssignService {
   }
 
   /**
-   * Redistribute buckets incrementally to alive nodes using round-robin. This 
minimizes bucket
-   * migration by only redistributing buckets from offline nodes.
+   * Check if the current assignments are unbalanced. Assignments are 
considered unbalanced if the
+   * difference between the max and min bucket count across nodes exceeds 1.
+   *
+   * @param aliveNodes List of alive nodes
+   * @param normalizedAssignments Current normalized assignments
+   * @return true if assignments are unbalanced
+   */
+  private boolean isUnbalanced(
+      List<AmsServerInfo> aliveNodes, Map<AmsServerInfo, List<String>> 
normalizedAssignments) {
+    if (aliveNodes.size() <= 1) {
+      return false;
+    }
+    int maxCount = Integer.MIN_VALUE;
+    int minCount = Integer.MAX_VALUE;
+    for (AmsServerInfo node : aliveNodes) {
+      int count = normalizedAssignments.getOrDefault(node, new 
ArrayList<>()).size();
+      maxCount = Math.max(maxCount, count);
+      minCount = Math.min(minCount, count);
+    }
+    // Balanced means the difference between max and min should be at most 1
+    return (maxCount - minCount) > 1;
+  }
+
+  /**
+   * Rebalance existing assignments to ensure even distribution across all 
alive nodes. Moves
+   * buckets from nodes with too many to nodes with too few, minimizing total 
migration.
+   *
+   * @param aliveNodes All alive nodes
+   * @param allBuckets All bucket IDs
+   * @param currentAssignments Current assignments map (will be modified)
+   */
+  private void rebalanceExistingAssignments(
+      List<AmsServerInfo> aliveNodes,
+      List<String> allBuckets,
+      Map<AmsServerInfo, List<String>> currentAssignments) {
+    int totalBuckets = allBuckets.size();
+    int totalNodes = aliveNodes.size();
+    int targetPerNode = totalBuckets / totalNodes;
+    int remainder = totalBuckets % totalNodes;
+
+    // Collect excess buckets from nodes that have too many
+    List<String> excessBuckets = new ArrayList<>();
+
+    // Sort nodes by current bucket count descending, so we take from the most 
loaded first
+    List<AmsServerInfo> sortedNodes = new ArrayList<>(aliveNodes);
+    sortedNodes.sort(
+        (n1, n2) -> {
+          int c1 = currentAssignments.getOrDefault(n1, new 
ArrayList<>()).size();
+          int c2 = currentAssignments.getOrDefault(n2, new 
ArrayList<>()).size();
+          return Integer.compare(c2, c1);
+        });
+
+    // First pass: determine each node's target and collect excess buckets
+    // The first 'remainder' nodes (sorted by count desc) get targetPerNode + 
1,
+    // the rest get targetPerNode
+    int remainderSlots = remainder;
+    for (AmsServerInfo node : sortedNodes) {
+      int nodeTarget = targetPerNode + (remainderSlots > 0 ? 1 : 0);
+      List<String> buckets = currentAssignments.getOrDefault(node, new 
ArrayList<>());
+      if (buckets.size() > nodeTarget) {
+        // Remove excess buckets from the end of the list
+        int excess = buckets.size() - nodeTarget;
+        for (int i = 0; i < excess; i++) {
+          excessBuckets.add(buckets.remove(buckets.size() - 1));
+        }
+        LOG.info(
+            "Collected {} excess buckets from node {} (had: {}, target: {})",
+            excess,
+            node,
+            buckets.size() + excess,
+            nodeTarget);
+      }
+      if (remainderSlots > 0) {
+        remainderSlots--;
+      }
+    }
+
+    // Second pass: distribute excess buckets to nodes that have fewer than 
target
+    if (!excessBuckets.isEmpty()) {
+      redistributeBucketsIncrementally(aliveNodes, excessBuckets, 
currentAssignments);
+    }
+  }
+
+  /**
+   * Redistribute buckets incrementally to alive nodes, always assigning each 
bucket to the node
+   * with the fewest buckets. This ensures even distribution regardless of the 
current state.
    *
    * @param aliveNodes List of alive nodes
    * @param bucketsToRedistribute Buckets to redistribute (from offline nodes)
@@ -190,12 +287,18 @@ public class AmsAssignService {
       return;
     }
 
-    // Distribute buckets using round-robin to minimize migration
-    int nodeIndex = 0;
+    // Assign each bucket to the node with the fewest buckets to ensure even 
distribution
     for (String bucketId : bucketsToRedistribute) {
-      AmsServerInfo node = aliveNodes.get(nodeIndex % aliveNodes.size());
-      currentAssignments.get(node).add(bucketId);
-      nodeIndex++;
+      AmsServerInfo minNode = null;
+      int minCount = Integer.MAX_VALUE;
+      for (AmsServerInfo node : aliveNodes) {
+        int count = currentAssignments.get(node).size();
+        if (count < minCount) {
+          minCount = count;
+          minNode = node;
+        }
+      }
+      currentAssignments.get(minNode).add(bucketId);
     }
   }
 
@@ -219,13 +322,24 @@ public class AmsAssignService {
       return;
     }
 
-    // Calculate how many buckets each new node should get
-    int bucketsPerNewNode = targetBucketsPerNode;
+    // Determine which alive nodes already have extra buckets (i.e. 
targetBucketsPerNode + 1)
+    // so that remainder slots are distributed fairly across all nodes, not 
just new ones.
+    int remainderSlotsTaken = 0;
+    for (AmsServerInfo node : aliveNodes) {
+      if (!newNodes.contains(node)) {
+        int count = currentAssignments.getOrDefault(node, new 
ArrayList<>()).size();
+        if (count > targetBucketsPerNode) {
+          remainderSlotsTaken++;
+        }
+      }
+    }
+
     int newNodeIndex = 0;
     for (AmsServerInfo newNode : newNodes) {
-      // First 'remainder' nodes get one extra bucket
-      int targetForNewNode = bucketsPerNewNode + (newNodeIndex < remainder ? 1 
: 0);
-      int currentCount = currentAssignments.get(newNode).size();
+      // Assign extra bucket only if there are remaining remainder slots
+      int targetForNewNode =
+          targetBucketsPerNode + ((remainderSlotsTaken + newNodeIndex) < 
remainder ? 1 : 0);
+      int currentCount = currentAssignments.getOrDefault(newNode, new 
ArrayList<>()).size();
       int needed = targetForNewNode - currentCount;
 
       if (needed > 0) {
@@ -380,41 +494,61 @@ public class AmsAssignService {
       Map<AmsServerInfo, List<String>> currentAssignments,
       Map<String, AmsServerInfo> aliveNodeMap,
       Set<AmsServerInfo> currentAssignedNodes) {
-    Set<AmsServerInfo> aliveNodeSet = new HashSet<>(aliveNodes);
-    Set<AmsServerInfo> newNodes = new HashSet<>(aliveNodeSet);
-    newNodes.removeAll(currentAssignedNodes);
-
-    Set<AmsServerInfo> offlineNodes = new HashSet<>();
-    for (AmsServerInfo storedNode : currentAssignments.keySet()) {
-      if (!aliveNodeMap.containsKey(getNodeKey(storedNode))) {
-        offlineNodes.add(storedNode);
+    // Build nodeKey sets for comparison instead of relying on 
AmsServerInfo.equals(),
+    // because ZkBucketAssignStore.parseNodeKey() only sets host and 
thriftBindPort (restBindPort
+    // is null), which would cause equals() mismatch with the full 
AmsServerInfo from
+    // getAliveNodes().
+    Set<String> assignedNodeKeys = new HashSet<>();
+    for (AmsServerInfo node : currentAssignedNodes) {
+      assignedNodeKeys.add(getNodeKey(node));
+    }
+    Set<AmsServerInfo> newNodes = new HashSet<>();
+    for (AmsServerInfo aliveNode : aliveNodes) {
+      if (!assignedNodeKeys.contains(getNodeKey(aliveNode))) {
+        newNodes.add(aliveNode);
       }
     }
 
+    Set<AmsServerInfo> offlineNodes = new HashSet<>();
     long currentTime = System.currentTimeMillis();
     Set<String> aliveNodeKeys = new HashSet<>();
     for (AmsServerInfo node : aliveNodes) {
       aliveNodeKeys.add(getNodeKey(node));
     }
+
     for (AmsServerInfo node : currentAssignedNodes) {
       String nodeKey = getNodeKey(node);
+      // If the node is currently alive, it should never be considered offline,
+      // even if its lastUpdateTime is stale (e.g. node restarted within 
nodeOfflineTimeoutMs
+      // but lastUpdateTime was not refreshed while it was down).
       if (aliveNodeKeys.contains(nodeKey)) {
-        try {
-          long lastUpdateTime = assignStore.getLastUpdateTime(node);
-          if (lastUpdateTime > 0 && (currentTime - lastUpdateTime) > 
nodeOfflineTimeoutMs) {
-            for (AmsServerInfo storedNode : currentAssignments.keySet()) {
-              if (getNodeKey(storedNode).equals(nodeKey)) {
-                offlineNodes.add(storedNode);
-                break;
-              }
-            }
-            LOG.warn(
-                "Node {} is considered offline due to timeout. Last update: 
{}",
-                node,
-                lastUpdateTime);
-          }
-        } catch (BucketAssignStoreException e) {
-          LOG.warn("Failed to get last update time for node {}, treating as 
offline", node, e);
+        continue;
+      }
+      try {
+        long lastUpdateTime = assignStore.getLastUpdateTime(node);
+        boolean shouldMarkOffline;
+        if (lastUpdateTime <= 0) {
+          // Missing timestamp means the node's saveAssignments never 
completed its
+          // updateLastUpdateTime call (e.g. leader crashed between the two ZK 
writes)
+          // or the store data was corrupted.  Since the node is already 
absent from
+          // the alive list, treat it as offline immediately to avoid stranded 
buckets.
+          shouldMarkOffline = true;
+          LOG.warn(
+              "Node {} is considered offline (missing last update time, not in 
alive list)", node);
+        } else if ((currentTime - lastUpdateTime) > nodeOfflineTimeoutMs) {
+          shouldMarkOffline = true;
+          LOG.warn(
+              "Node {} is considered offline due to timeout. Last update: {}",
+              node,
+              lastUpdateTime);
+        } else {
+          shouldMarkOffline = false;
+          LOG.debug(
+              "Node {} is not in alive list but heartbeat not timeout (last 
update: {}), waiting for timeout",
+              node,
+              lastUpdateTime);
+        }
+        if (shouldMarkOffline) {
           for (AmsServerInfo storedNode : currentAssignments.keySet()) {
             if (getNodeKey(storedNode).equals(nodeKey)) {
               offlineNodes.add(storedNode);
@@ -422,6 +556,14 @@ public class AmsAssignService {
             }
           }
         }
+      } catch (BucketAssignStoreException e) {
+        LOG.warn("Failed to get last update time for node {}, treating as 
offline", node, e);
+        for (AmsServerInfo storedNode : currentAssignments.keySet()) {
+          if (getNodeKey(storedNode).equals(nodeKey)) {
+            offlineNodes.add(storedNode);
+            break;
+          }
+        }
       }
     }
     return new NodeChangeResult(newNodes, offlineNodes);
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
index e17672768..efc1c375b 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
@@ -157,7 +157,8 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
     this.bucketAssignStore = bucketAssignStore;
     this.haContainer = haContainer;
     this.isMasterSlaveMode =
-        haContainer != null && 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE);
+        haContainer != null
+            && 
serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE);
     this.tableHandlerChain = new TableRuntimeHandlerImpl();
     this.planExecutor =
         Executors.newCachedThreadPool(
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/ha/DataBaseHighAvailabilityContainer.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/ha/DataBaseHighAvailabilityContainer.java
index 7d6b6b37e..1aeaba583 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/ha/DataBaseHighAvailabilityContainer.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/ha/DataBaseHighAvailabilityContainer.java
@@ -141,7 +141,8 @@ public class DataBaseHighAvailabilityContainer extends 
PersistentBase
 
   @Override
   public void registerAndElect() throws Exception {
-    boolean isMasterSlaveMode = 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE);
+    boolean isMasterSlaveMode =
+        serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE);
     if (!isMasterSlaveMode) {
       LOG.debug("Master-slave mode is not enabled, skip node registration");
       return;
@@ -220,7 +221,8 @@ public class DataBaseHighAvailabilityContainer extends 
PersistentBase
     }
     // Remove this node from bucket_assignments so the leader immediately 
stops seeing it
     // as alive without waiting for the heartbeat TTL to expire.
-    boolean isMasterSlaveMode = 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE);
+    boolean isMasterSlaveMode =
+        serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE);
     if (isMasterSlaveMode) {
       try {
         String nodeKey = getNodeKey();
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/ha/ZkHighAvailabilityContainer.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/ha/ZkHighAvailabilityContainer.java
index 5c93a6777..9d557737c 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/ha/ZkHighAvailabilityContainer.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/ha/ZkHighAvailabilityContainer.java
@@ -67,7 +67,7 @@ public class ZkHighAvailabilityContainer implements 
HighAvailabilityContainer, L
   private String registeredNodePath;
 
   public ZkHighAvailabilityContainer(Configurations serviceConfig) throws 
Exception {
-    this.isMasterSlaveMode = 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE);
+    this.isMasterSlaveMode = 
serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE);
     if (serviceConfig.getBoolean(AmoroManagementConf.HA_ENABLE)) {
       String zkServerAddress = 
serviceConfig.getString(AmoroManagementConf.HA_ZOOKEEPER_ADDRESS);
       int zkSessionTimeout =
@@ -212,6 +212,29 @@ public class ZkHighAvailabilityContainer implements 
HighAvailabilityContainer, L
         tableServiceServerInfo.toString(),
         optimizingServiceServerInfo.toString());
     followerLatch = new CountDownLatch(1);
+    // Write server info to ZK master paths on leadership gained, so that both 
master-slave mode
+    // (which skips waitLeaderShip()) and active-standby mode have the correct 
JSON data in ZK.
+    try {
+      CuratorOp tableServiceMasterPathOp =
+          zkClient
+              .transactionOp()
+              .setData()
+              .forPath(
+                  tableServiceMasterPath,
+                  JacksonUtil.toJSONString(tableServiceServerInfo)
+                      .getBytes(StandardCharsets.UTF_8));
+      CuratorOp optimizingServiceMasterPathOp =
+          zkClient
+              .transactionOp()
+              .setData()
+              .forPath(
+                  optimizingServiceMasterPath,
+                  JacksonUtil.toJSONString(optimizingServiceServerInfo)
+                      .getBytes(StandardCharsets.UTF_8));
+      zkClient.transaction().forOperations(tableServiceMasterPathOp, 
optimizingServiceMasterPathOp);
+    } catch (Exception e) {
+      LOG.error("Failed to write server info to ZK master paths on leadership 
gained", e);
+    }
   }
 
   @Override
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/AbstractTableMetrics.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/AbstractTableMetrics.java
index 74e52f0b7..4778a2044 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/AbstractTableMetrics.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/AbstractTableMetrics.java
@@ -54,8 +54,16 @@ public abstract class AbstractTableMetrics {
 
   public void register(MetricRegistry registry) {
     if (globalRegistry == null) {
-      registerMetrics(registry);
-      globalRegistry = registry;
+      try {
+        registerMetrics(registry);
+        globalRegistry = registry;
+      } catch (Exception e) {
+        // Roll back any metrics that were partially registered before the 
failure so that the
+        // next retry call finds a clean state (globalRegistry stays null, 
partial keys removed).
+        registeredMetricKeys.forEach(registry::unregister);
+        registeredMetricKeys.clear();
+        throw e;
+      }
     }
   }
 
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/DefaultTableService.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/DefaultTableService.java
index c64b7d923..6e916bfd7 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/table/DefaultTableService.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/table/DefaultTableService.java
@@ -105,6 +105,27 @@ public class DefaultTableService extends PersistentBase 
implements TableService
   // Master-slave mode related fields
   private ScheduledExecutorService bucketTableSyncScheduler;
   private volatile List<String> assignedBucketIds = new ArrayList<>();
+
+  /**
+   * Serializes the "read bucket counts → select bucket" critical section so 
that concurrent
+   * table-explorer threads cannot pick the same bucket ID within the same 
exploration cycle.
+   *
+   * <p>Using only a DB-side count is insufficient: {@code insertRuntime} may 
be executed inside a
+   * larger uncommitted {@code doAsTransaction}, so a concurrent thread 
reading the DB before that
+   * transaction commits still sees the old count and picks the same bucket. 
The {@code
+   * pendingBucketAssignments} map below compensates by tracking in-memory 
reservations that have
+   * been decided but may not yet be visible in the DB.
+   */
+  private final Object bucketAssignLock = new Object();
+
+  /**
+   * Tracks bucket-ID reservations made during the current exploration cycle 
that have been decided
+   * (and recorded in-memory) but whose DB writes may not yet be committed. 
This map is always
+   * accessed under {@code bucketAssignLock} and is cleared at the start of 
each exploration cycle
+   * so that it never drifts from the committed DB state across cycles.
+   */
+  private final Map<String, Integer> pendingBucketAssignments = new 
HashMap<>();
+
   private final long bucketTableSyncInterval;
 
   public DefaultTableService(
@@ -128,7 +149,7 @@ public class DefaultTableService extends PersistentBase 
implements TableService
     this.tableRuntimeFactory.withTableLoader(this::loadTable);
     this.haContainer = haContainer;
     this.bucketAssignStore = bucketAssignStore;
-    this.isMasterSlaveMode = 
configuration.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE);
+    this.isMasterSlaveMode = 
configuration.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE);
     this.bucketTableSyncInterval =
         
configuration.get(AmoroManagementConf.HA_BUCKET_TABLE_SYNC_INTERVAL).toMillis();
   }
@@ -240,6 +261,9 @@ public class DefaultTableService extends PersistentBase 
implements TableService
         continue;
       }
       List<TableRuntimeState> states = 
statesMap.get(tableRuntimeMeta.getTableId());
+      if (states == null) {
+        states = Collections.emptyList();
+      }
       Optional<TableRuntime> tableRuntime =
           createTableRuntime(identifier, tableRuntimeMeta, states);
       if (!tableRuntime.isPresent()) {
@@ -404,7 +428,8 @@ public class DefaultTableService extends PersistentBase 
implements TableService
                         return a;
                       }));
 
-      // Find tables that should be added (in DB but not in memory)
+      // Snapshot the current in-memory table IDs for the removal-detection 
loop below.
+      // The "add" loop uses a live containsKey check so it does not rely on 
this snapshot.
       Set<Long> currentTableIds = new HashSet<>(tableRuntimeMap.keySet());
       Set<Long> dbTableIds =
           tableRuntimeMetaList.stream()
@@ -414,31 +439,34 @@ public class DefaultTableService extends PersistentBase 
implements TableService
       // Add new tables
       for (TableRuntimeMeta tableRuntimeMeta : tableRuntimeMetaList) {
         Long tableId = tableRuntimeMeta.getTableId();
-        if (!currentTableIds.contains(tableId)) {
-          ServerTableIdentifier identifier = identifierMap.get(tableId);
-          if (identifier == null) {
-            LOG.warn("No available table identifier found for table runtime 
meta id={}", tableId);
-            continue;
-          }
-          List<TableRuntimeState> states = statesMap.get(tableId);
-          // Use empty list if states is null to avoid NullPointerException
-          if (states == null) {
-            states = Collections.emptyList();
-          }
-          Optional<TableRuntime> tableRuntime =
-              createTableRuntime(identifier, tableRuntimeMeta, states);
-          if (tableRuntime.isPresent()) {
-            TableRuntime runtime = tableRuntime.get();
-            
runtime.registerMetric(MetricManager.getInstance().getGlobalRegistry());
-            tableRuntimeMap.put(tableId, runtime);
-            if (headHandler != null) {
-              AmoroTable<?> table = loadTable(identifier);
-              if (table != null) {
-                headHandler.fireTableAdded(table, runtime);
-              }
+        // Use a live containsKey check instead of the snapshot to guard 
against any concurrent
+        // addition (e.g. from exploreTableRuntimes running on a parallel 
thread).
+        if (tableRuntimeMap.containsKey(tableId)) {
+          continue;
+        }
+        ServerTableIdentifier identifier = identifierMap.get(tableId);
+        if (identifier == null) {
+          LOG.warn("No available table identifier found for table runtime meta 
id={}", tableId);
+          continue;
+        }
+        List<TableRuntimeState> states = statesMap.get(tableId);
+        // Use empty list if states is null to avoid NullPointerException
+        if (states == null) {
+          states = Collections.emptyList();
+        }
+        Optional<TableRuntime> tableRuntime =
+            createTableRuntime(identifier, tableRuntimeMeta, states);
+        if (tableRuntime.isPresent()) {
+          TableRuntime runtime = tableRuntime.get();
+          
runtime.registerMetric(MetricManager.getInstance().getGlobalRegistry());
+          tableRuntimeMap.put(tableId, runtime);
+          if (headHandler != null) {
+            AmoroTable<?> table = loadTable(identifier);
+            if (table != null) {
+              headHandler.fireTableAdded(table, runtime);
             }
-            LOG.info("Added table {} for bucketId {}", tableId, 
tableRuntimeMeta.getBucketId());
           }
+          LOG.info("Added table {} for bucketId {}", tableId, 
tableRuntimeMeta.getBucketId());
         }
       }
 
@@ -471,11 +499,17 @@ public class DefaultTableService extends PersistentBase 
implements TableService
             if (headHandler != null) {
               headHandler.fireTableRemoved(tableRuntime);
             }
-            tableRuntime.dispose();
+            // Only unload from memory; do NOT call dispose() which would 
delete the table from DB.
+            // This node is simply no longer responsible for the table (bucket 
reassigned or the
+            // runtime row was already cleaned up elsewhere). Deleting from DB 
here would destroy
+            // a live table managed by another node.
+            tableRuntime.unregisterMetric();
             tableRuntimeMap.remove(tableId);
-            LOG.info("Removed table {} as it's no longer assigned to this 
node", tableId);
+            LOG.info(
+                "Unloaded table {} runtime from this node (bucket no longer 
assigned here)",
+                tableId);
           } catch (Exception e) {
-            LOG.error("Error occurred while removing tableRuntime of table 
{}", tableId, e);
+            LOG.error("Error occurred while unloading tableRuntime of table 
{}", tableId, e);
           }
         }
       }
@@ -485,10 +519,12 @@ public class DefaultTableService extends PersistentBase 
implements TableService
   }
 
   /**
-   * Assign bucketId to a table using min-heap strategy. This ensures tables 
are evenly distributed
-   * across bucketIds.
+   * Selects the bucket with the fewest assigned tables using a min-heap.
    *
-   * @return The assigned bucketId, or null if assignment fails
+   * <p><b>Must be called while holding {@code bucketAssignLock}.</b> The 
method merges committed DB
+   * counts with {@code pendingBucketAssignments} (reservations made in the 
current exploration
+   * cycle whose DB writes may not yet be committed) so that concurrent 
callers within the same
+   * cycle always see each other's reservations and never select the same 
bucket.
    */
   private String assignBucketIdForTable() {
     try {
@@ -497,9 +533,9 @@ public class DefaultTableService extends PersistentBase 
implements TableService
               TableRuntimeMapper.class,
               (TableRuntimeMapper mapper) -> mapper.countTablesByBucketId());
 
-      Map<String, Integer> bucketTableCount = new HashMap<>();
       int bucketIdTotalCount =
           
serverConfiguration.getInteger(AmoroManagementConf.HA_BUCKET_ID_TOTAL_COUNT);
+      Map<String, Integer> bucketTableCount = new HashMap<>();
       for (int i = 1; i <= bucketIdTotalCount; i++) {
         bucketTableCount.put(String.valueOf(i), 0);
       }
@@ -511,10 +547,15 @@ public class DefaultTableService extends PersistentBase 
implements TableService
         }
       }
 
+      // Overlay in-memory reservations from this exploration cycle: these 
writes may not yet be
+      // committed to the DB, so the DB count alone would be stale for 
concurrent callers.
+      pendingBucketAssignments.forEach(
+          (bucket, pending) -> bucketTableCount.merge(bucket, pending, 
Integer::sum));
+
       PriorityQueue<Map.Entry<String, Integer>> minHeap =
           new PriorityQueue<>(
               Comparator.<Map.Entry<String, 
Integer>>comparingInt(Map.Entry::getValue)
-                  .thenComparing(Map.Entry::getKey));
+                  .thenComparingInt(e -> Integer.parseInt(e.getKey())));
 
       for (Map.Entry<String, Integer> entry : bucketTableCount.entrySet()) {
         minHeap.offer(new AbstractMap.SimpleEntry<>(entry.getKey(), 
entry.getValue()));
@@ -523,11 +564,12 @@ public class DefaultTableService extends PersistentBase 
implements TableService
       if (!minHeap.isEmpty()) {
         Map.Entry<String, Integer> selected = minHeap.poll();
         String assignedBucketId = selected.getKey();
-        int tableCount = selected.getValue();
+        // Record reservation immediately so the next caller (within the same 
lock-cycle) sees it.
+        pendingBucketAssignments.merge(assignedBucketId, 1, Integer::sum);
         LOG.debug(
-            "Assigned bucketId {} to new table (min table count: {})",
+            "Assigned bucketId {} to new table (effective count: {})",
             assignedBucketId,
-            tableCount);
+            selected.getValue());
         return assignedBucketId;
       }
     } catch (Exception e) {
@@ -550,6 +592,10 @@ public class DefaultTableService extends PersistentBase 
implements TableService
       throw new IllegalStateException("TableService is not initialized");
     }
     long start = System.currentTimeMillis();
+    // Reset the in-memory pending map so the new cycle starts from committed 
DB state.
+    synchronized (bucketAssignLock) {
+      pendingBucketAssignments.clear();
+    }
     List<ServerCatalog> externalCatalogs = catalogManager.getServerCatalogs();
     List<String> externalCatalogNames =
         
externalCatalogs.stream().map(ServerCatalog::name).collect(Collectors.toList());
@@ -700,6 +746,23 @@ public class DefaultTableService extends PersistentBase 
implements TableService
               }
             });
     taskFutures.forEach(CompletableFuture::join);
+
+    // In master-slave mode, historical tables already registered in Amoro DB 
may have a null
+    // bucket_id if they predate the master-slave feature. Unlike new tables 
(handled above via
+    // syncTable), these tables are already in serverTableIdentifiers and are 
skipped by the
+    // Sets.difference logic. We must explicitly iterate over them here so the 
leader can assign
+    // bucket IDs, mirroring the behaviour of exploreInternalCatalog.
+    serverTableIdentifiers.values().stream()
+        .filter(i -> !tableRuntimeMap.containsKey(i.getId()))
+        .filter(i -> tableIdentifiers.contains(new TableIdentity(i)))
+        .forEach(
+            i -> {
+              try {
+                triggerTableAdded(externalCatalog, i);
+              } catch (Exception e) {
+                LOG.error("TableExplorer failed to assign bucketId for 
existing table {}", i, e);
+              }
+            });
   }
 
   private void exploreInternalCatalog(InternalCatalog internalCatalog) {
@@ -802,7 +865,6 @@ public class DefaultTableService extends PersistentBase 
implements TableService
     // In master-slave mode (fully wired), assign bucketId to the table if 
it's not assigned yet.
     // Only leader node should assign bucketIds; follower may still persist 
the table with null
     // bucketId (e.g. onTableCreated on follower), and leader will assign 
later via exploration.
-    String assignedBucketId = null;
     if (isMasterSlaveMode && haContainer != null && bucketAssignStore != null) 
{
       if (haContainer.hasLeadership()) {
         TableRuntimeMeta existingMeta =
@@ -814,40 +876,51 @@ public class DefaultTableService extends PersistentBase 
implements TableService
           if (existingMeta.getBucketId() != null) {
             return true; // already assigned
           }
-          assignedBucketId = assignBucketIdForTable();
-          if (assignedBucketId != null) {
-            String finalAssignedBucketId = assignedBucketId;
-            long updated =
-                updateAs(
-                    TableRuntimeMapper.class,
-                    mapper ->
-                        mapper.updateBucketIdIfNull(
-                            serverTableIdentifier.getId(), 
finalAssignedBucketId));
-            if (updated == 1) {
-              LOG.info(
-                  "Assigned bucketId {} to existing table {} (was null)",
-                  assignedBucketId,
-                  serverTableIdentifier);
-            } else {
-              LOG.debug(
-                  "Skipped backfill bucketId {} for table {} (row missing or 
bucket_id already set)",
-                  assignedBucketId,
-                  serverTableIdentifier);
+          // Existing row with null bucket_id: assign and update atomically so 
that concurrent
+          // table-explorer threads reading identical DB counts cannot select 
the same bucket.
+          synchronized (bucketAssignLock) {
+            String assignedBucketId = assignBucketIdForTable();
+            if (assignedBucketId != null) {
+              long updated =
+                  updateAs(
+                      TableRuntimeMapper.class,
+                      mapper ->
+                          mapper.updateBucketIdIfNull(
+                              serverTableIdentifier.getId(), 
assignedBucketId));
+              if (updated == 1) {
+                LOG.info(
+                    "Assigned bucketId {} to existing table {} (was null)",
+                    assignedBucketId,
+                    serverTableIdentifier);
+              } else {
+                LOG.debug(
+                    "Skipped backfill bucketId {} for table {} (row missing or 
bucket_id already set)",
+                    assignedBucketId,
+                    serverTableIdentifier);
+              }
             }
           }
           return true; // handled existing row (assigned or failed to assign)
         }
-        assignedBucketId = assignBucketIdForTable();
-        if (assignedBucketId != null) {
-          meta.setBucketId(assignedBucketId);
-          LOG.info("Assigned bucketId {} to table {}", assignedBucketId, 
serverTableIdentifier);
-        } else {
-          LOG.warn(
-              "Failed to assign bucketId to table {}, will be assigned later",
-              serverTableIdentifier);
+        // New table: assign bucket and insert runtime atomically so that 
concurrent threads
+        // cannot read identical DB counts and select the same bucket.
+        synchronized (bucketAssignLock) {
+          String assignedBucketId = assignBucketIdForTable();
+          if (assignedBucketId != null) {
+            meta.setBucketId(assignedBucketId);
+            LOG.info("Assigned bucketId {} to table {}", assignedBucketId, 
serverTableIdentifier);
+          } else {
+            LOG.warn(
+                "Failed to assign bucketId to table {}, will be assigned 
later",
+                serverTableIdentifier);
+          }
+          doAs(TableRuntimeMapper.class, mapper -> mapper.insertRuntime(meta));
         }
+        return true;
       }
-      // else: follower does not assign; meta.bucketId remains null until 
leader assigns
+      // Follower: persist the table with null bucketId; leader will assign 
later.
+      doAs(TableRuntimeMapper.class, mapper -> mapper.insertRuntime(meta));
+      return true;
     }
 
     doAs(TableRuntimeMapper.class, mapper -> mapper.insertRuntime(meta));
@@ -855,9 +928,6 @@ public class DefaultTableService extends PersistentBase 
implements TableService
     // Only skip local runtime creation when master-slave mode is fully wired 
(bucketAssignStore
     // is non-null). When bucketAssignStore is null (e.g. 3-arg test 
constructor), fall through
     // and create the runtime in memory as in non-master-slave mode.
-    if (isMasterSlaveMode && haContainer != null && bucketAssignStore != null) 
{
-      return true;
-    }
 
     Optional<TableRuntime> tableRuntimeOpt =
         createTableRuntime(serverTableIdentifier, meta, 
Collections.emptyList());
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestAmsAssignService.java 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestAmsAssignService.java
index e441a6d1f..91fad6436 100644
--- a/amoro-ams/src/test/java/org/apache/amoro/server/TestAmsAssignService.java
+++ b/amoro-ams/src/test/java/org/apache/amoro/server/TestAmsAssignService.java
@@ -78,9 +78,9 @@ public class TestAmsAssignService {
     serviceConfig.setBoolean(AmoroManagementConf.HA_ENABLE, true);
     serviceConfig.setString(AmoroManagementConf.HA_ZOOKEEPER_ADDRESS, 
"127.0.0.1:2181");
     serviceConfig.setString(AmoroManagementConf.HA_CLUSTER_NAME, 
"test-cluster");
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     serviceConfig.setInteger(AmoroManagementConf.HA_BUCKET_ID_TOTAL_COUNT, 
100);
-    serviceConfig.set(AmoroManagementConf.HA_NODE_OFFLINE_TIMEOUT, 
java.time.Duration.ofMinutes(5));
+    serviceConfig.set(AmoroManagementConf.HA_NODE_OFFLINE_TIMEOUT, 
java.time.Duration.ofMillis(50));
 
     haContainer = createContainerWithMockZk();
 
@@ -381,6 +381,51 @@ public class TestAmsAssignService {
     }
   }
 
+  @Test
+  public void testOfflineNodeWithMissingLastUpdateTime() throws Exception {
+    // Verify that a node absent from the alive list with lastUpdateTime == 0
+    // is still reclaimed instead of being stranded forever.
+    haContainer.registerAndElect();
+    Configurations config2 = createNodeConfig("127.0.0.2", 1262, 1632);
+    HighAvailabilityContainer haContainer2 = 
createContainerWithMockZk(config2);
+    haContainer2.registerAndElect();
+
+    try {
+      Thread.sleep(100);
+
+      // Initial assignment — both nodes get buckets
+      assignService.doAssign();
+      Map<AmsServerInfo, List<String>> initialAssignments = 
mockAssignStore.getAllAssignments();
+      Assert.assertEquals("Should have 2 nodes", 2, initialAssignments.size());
+
+      // Simulate node2 going offline (remove from ZK)
+      mockZkState.deleteNodeByHost("127.0.0.2");
+
+      // Clear node2's lastUpdateTime to simulate the edge case where
+      // saveAssignments wrote the assignment but crashed before
+      // updateLastUpdateTime completed (two non-atomic ZK writes).
+      mockAssignStore.clearLastUpdateTime("127.0.0.2");
+
+      Thread.sleep(100);
+
+      // Trigger reassignment — node2 should be reclaimed even without a 
timestamp
+      assignService.doAssign();
+
+      Map<AmsServerInfo, List<String>> newAssignments = 
mockAssignStore.getAllAssignments();
+      Assert.assertEquals(
+          "Node with missing lastUpdateTime should be reclaimed", 1, 
newAssignments.size());
+
+      List<String> remainingBuckets = 
newAssignments.values().iterator().next();
+      Assert.assertEquals("All buckets should be redistributed", 100, 
remainingBuckets.size());
+    } finally {
+      try {
+        haContainer2.close();
+      } catch (Exception e) {
+        // ignore
+      }
+    }
+  }
+
   @Test
   public void testServiceStartStop() {
     // Test that service can start and stop without errors
@@ -428,9 +473,9 @@ public class TestAmsAssignService {
     config.setBoolean(AmoroManagementConf.HA_ENABLE, true);
     config.setString(AmoroManagementConf.HA_ZOOKEEPER_ADDRESS, 
"127.0.0.1:2181");
     config.setString(AmoroManagementConf.HA_CLUSTER_NAME, "test-cluster");
-    config.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    config.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, true);
     config.setInteger(AmoroManagementConf.HA_BUCKET_ID_TOTAL_COUNT, 100);
-    config.set(AmoroManagementConf.HA_NODE_OFFLINE_TIMEOUT, 
java.time.Duration.ofMinutes(5));
+    config.set(AmoroManagementConf.HA_NODE_OFFLINE_TIMEOUT, 
java.time.Duration.ofMillis(50));
     return config;
   }
 
@@ -490,7 +535,7 @@ public class TestAmsAssignService {
         
ZkHighAvailabilityContainer.class.getDeclaredField("isMasterSlaveMode");
     isMasterSlaveModeField.setAccessible(true);
     isMasterSlaveModeField.set(
-        container, 
config.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE));
+        container, 
config.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE));
 
     if (config.getBoolean(AmoroManagementConf.HA_ENABLE)) {
       String haClusterName = 
config.getString(AmoroManagementConf.HA_CLUSTER_NAME);
@@ -881,6 +926,19 @@ public class TestAmsAssignService {
       return nodes;
     }
 
+    /**
+     * Clear lastUpdateTime for all nodes matching the given host, simulating 
a missing timestamp.
+     */
+    void clearLastUpdateTime(String host) {
+      List<String> toClear = new ArrayList<>();
+      for (String nodeKey : lastUpdateTimes.keySet()) {
+        if (nodeKey.startsWith(host + ":")) {
+          toClear.add(nodeKey);
+        }
+      }
+      toClear.forEach(lastUpdateTimes::remove);
+    }
+
     private AmsServerInfo parseNodeKey(String nodeKey) {
       String[] parts = nodeKey.split(":");
       if (parts.length != 2) {
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestHighAvailabilityContainer.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestHighAvailabilityContainer.java
index 19048896a..7a8411ae8 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestHighAvailabilityContainer.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestHighAvailabilityContainer.java
@@ -84,7 +84,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testRegistAndElectWithoutMasterSlaveMode() throws Exception {
     // Test that node registration is skipped when master-slave mode is 
disabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, false);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
false);
     haContainer = createContainerWithMockZk();
 
     // Should not throw exception and should not register node
@@ -100,7 +100,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testRegistAndElectWithMasterSlaveMode() throws Exception {
     // Test that node registration works when master-slave mode is enabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = createContainerWithMockZk();
 
     // Register node
@@ -128,7 +128,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWithoutMasterSlaveMode() throws Exception {
     // Test that getAliveNodes returns empty list when master-slave mode is 
disabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, false);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
false);
     haContainer = createContainerWithMockZk();
 
     List<AmsServerInfo> aliveNodes = haContainer.getAliveNodes();
@@ -142,7 +142,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWhenNotLeader() throws Exception {
     // Test that getAliveNodes returns empty list when not leader
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(false); // Not leader
     haContainer = createContainerWithMockZk();
 
@@ -158,7 +158,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesAsLeader() throws Exception {
     // Test that getAliveNodes returns nodes when leader
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(true); // Is leader
     haContainer = createContainerWithMockZk();
 
@@ -185,7 +185,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWithMultipleNodes() throws Exception {
     // Test that getAliveNodes returns all registered nodes
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(true); // Is leader
     haContainer = createContainerWithMockZk();
 
@@ -217,7 +217,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testCloseUnregistersNode() throws Exception {
     // Test that close() unregisters the node
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = createContainerWithMockZk();
 
     // Register node
@@ -240,7 +240,7 @@ public class TestHighAvailabilityContainer {
   @Test
   public void testHasLeadership() throws Exception {
     // Test hasLeadership() method
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(false); // Not leader initially
     haContainer = createContainerWithMockZk();
 
@@ -259,7 +259,7 @@ public class TestHighAvailabilityContainer {
   public void testRegistAndElectWithoutHAEnabled() throws Exception {
     // Test that registAndElect skips when HA is not enabled
     serviceConfig.setBoolean(AmoroManagementConf.HA_ENABLE, false);
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = new ZkHighAvailabilityContainer(serviceConfig);
 
     // Should not throw exception
@@ -309,7 +309,7 @@ public class TestHighAvailabilityContainer {
         
ZkHighAvailabilityContainer.class.getDeclaredField("isMasterSlaveMode");
     isMasterSlaveModeField.setAccessible(true);
     isMasterSlaveModeField.set(
-        container, 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE));
+        container, 
serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE));
 
     if (serviceConfig.getBoolean(AmoroManagementConf.HA_ENABLE)) {
       String haClusterName = 
serviceConfig.getString(AmoroManagementConf.HA_CLUSTER_NAME);
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/ha/TestZkHighAvailabilityContainer.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/ha/TestZkHighAvailabilityContainer.java
index 3f9433132..97b0d6d5c 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/ha/TestZkHighAvailabilityContainer.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/ha/TestZkHighAvailabilityContainer.java
@@ -83,7 +83,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testRegisterAndElectWithoutMasterSlaveMode() throws Exception {
     // Test that node registration is skipped when master-slave mode is 
disabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, false);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
false);
     haContainer = createContainerWithMockZk();
 
     // Should not throw exception and should not register node
@@ -99,7 +99,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testRegisterAndElectWithMasterSlaveMode() throws Exception {
     // Test that node registration works when master-slave mode is enabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = createContainerWithMockZk();
 
     // Register node
@@ -127,7 +127,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWithoutMasterSlaveMode() throws Exception {
     // Test that getAliveNodes returns empty list when master-slave mode is 
disabled
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, false);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
false);
     haContainer = createContainerWithMockZk();
 
     List<AmsServerInfo> aliveNodes = haContainer.getAliveNodes();
@@ -141,7 +141,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWhenNotLeader() throws Exception {
     // Test that getAliveNodes returns empty list when not leader
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(false); // Not leader
     haContainer = createContainerWithMockZk();
 
@@ -157,7 +157,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesAsLeader() throws Exception {
     // Test that getAliveNodes returns nodes when leader
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(true); // Is leader
     haContainer = createContainerWithMockZk();
 
@@ -184,7 +184,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testGetAliveNodesWithMultipleNodes() throws Exception {
     // Test that getAliveNodes returns all registered nodes
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(true); // Is leader
     haContainer = createContainerWithMockZk();
 
@@ -216,7 +216,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testCloseUnregistersNode() throws Exception {
     // Test that close() unregisters the node
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = createContainerWithMockZk();
 
     // Register node
@@ -239,7 +239,7 @@ public class TestZkHighAvailabilityContainer {
   @Test
   public void testHasLeadership() throws Exception {
     // Test hasLeadership() method
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     mockLeaderLatch = createMockLeaderLatch(false); // Not leader initially
     haContainer = createContainerWithMockZk();
 
@@ -258,7 +258,7 @@ public class TestZkHighAvailabilityContainer {
   public void testRegisterAndElectWithoutHAEnabled() throws Exception {
     // Test that registAndElect skips when HA is not enabled
     serviceConfig.setBoolean(AmoroManagementConf.HA_ENABLE, false);
-    serviceConfig.setBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE, true);
+    serviceConfig.setBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE, 
true);
     haContainer = new ZkHighAvailabilityContainer(serviceConfig);
 
     // Should not throw exception
@@ -312,7 +312,7 @@ public class TestZkHighAvailabilityContainer {
         
ZkHighAvailabilityContainer.class.getDeclaredField("isMasterSlaveMode");
     isMasterSlaveModeField.setAccessible(true);
     isMasterSlaveModeField.set(
-        container, 
serviceConfig.getBoolean(AmoroManagementConf.USE_MASTER_SLAVE_MODE));
+        container, 
serviceConfig.getBoolean(AmoroManagementConf.HA_USE_MASTER_SLAVE_MODE));
 
     if (serviceConfig.getBoolean(AmoroManagementConf.HA_ENABLE)) {
       String haClusterName = 
serviceConfig.getString(AmoroManagementConf.HA_CLUSTER_NAME);
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
index bda30c27f..af002bc0f 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
@@ -52,27 +52,58 @@ public class AbstractOptimizerOperator implements 
Serializable {
   private volatile boolean stopped = false;
   private transient volatile AmsNodeManager amsNodeManager;
   private transient volatile ThriftAmsNodeManager thriftAmsNodeManager;
+  // Throttle the "no AMS nodes discovered" warning to at most once per minute.
+  private volatile long lastEmptyNodesWarnTime = 0;
 
   public AbstractOptimizerOperator(OptimizerConfig config) {
     Preconditions.checkNotNull(config);
     this.config = config;
-    if (config.isMasterSlaveMode()) {
-      String amsUrl = config.getAmsUrl();
-      if (amsUrl.startsWith("zookeeper://")) {
-        // ZK HA mode: discover nodes from ZooKeeper ephemeral nodes
-        try {
-          this.amsNodeManager = new AmsNodeManager(amsUrl);
-          LOG.info("Initialized ZK AmsNodeManager for master-slave mode");
-        } catch (Exception e) {
-          LOG.warn("Failed to initialize AmsNodeManager, will use single AMS 
URL", e);
+    // Node managers are initialized lazily in ensureNodeManagerInitialized() 
rather than here,
+    // because Flink serializes operators on the client side and deserializes 
them on TaskManagers.
+    // Since amsNodeManager and thriftAmsNodeManager are transient (they hold 
non-serializable
+    // ZK/Thrift clients), they would be lost after deserialization. Lazy 
initialization in
+    // getAmsNodeUrls() ensures they are properly created on the TaskManager 
where they are needed.
+  }
+
+  /**
+   * Lazily initialize the node manager after Flink deserialization.
+   *
+   * <p>Flink serializes operators (including 
OptimizerExecutor/OptimizerToucher) on the client side
+   * and deserializes them on TaskManagers. Since {@code amsNodeManager} and 
{@code
+   * thriftAmsNodeManager} are {@code transient} (they hold non-serializable 
ZK/Thrift clients),
+   * they are {@code null} after deserialization. This method re-creates them 
on demand.
+   */
+  private void ensureNodeManagerInitialized() {
+    if (!config.isMasterSlaveMode()) {
+      return;
+    }
+    String amsUrl = config.getAmsUrl();
+    if (amsUrl.startsWith("zookeeper://")) {
+      if (amsNodeManager == null) {
+        synchronized (this) {
+          if (amsNodeManager == null) {
+            try {
+              this.amsNodeManager = new AmsNodeManager(amsUrl);
+              LOG.info("Initialized ZK AmsNodeManager for master-slave mode 
(url: {})", amsUrl);
+            } catch (Exception e) {
+              LOG.warn("Failed to initialize AmsNodeManager, will use single 
AMS URL", e);
+            }
+          }
         }
-      } else {
-        // DB HA mode: discover nodes via getOptimizingNodeUrls() Thrift RPC
-        try {
-          this.thriftAmsNodeManager = new ThriftAmsNodeManager(amsUrl);
-          LOG.info("Initialized ThriftAmsNodeManager for master-slave mode (DB 
HA)");
-        } catch (Exception e) {
-          LOG.warn("Failed to initialize ThriftAmsNodeManager, will use single 
AMS URL", e);
+      }
+    } else {
+      if (thriftAmsNodeManager == null) {
+        synchronized (this) {
+          if (thriftAmsNodeManager == null) {
+            try {
+              this.thriftAmsNodeManager = new ThriftAmsNodeManager(amsUrl);
+              LOG.info(
+                  "Initialized ThriftAmsNodeManager for master-slave mode (DB 
HA, url: {})",
+                  amsUrl);
+            } catch (Exception e) {
+              LOG.warn("Failed to initialize ThriftAmsNodeManager, will use 
single AMS URL", e);
+            }
+          }
         }
       }
     }
@@ -80,11 +111,13 @@ public class AbstractOptimizerOperator implements 
Serializable {
 
   /** Get the AmsNodeManager instance if available (ZK mode). */
   protected AmsNodeManager getAmsNodeManager() {
+    ensureNodeManagerInitialized();
     return amsNodeManager;
   }
 
   /** Get the ThriftAmsNodeManager instance if available (DB mode). */
   protected ThriftAmsNodeManager getThriftAmsNodeManager() {
+    ensureNodeManagerInitialized();
     return thriftAmsNodeManager;
   }
 
@@ -93,6 +126,7 @@ public class AbstractOptimizerOperator implements 
Serializable {
    * whether to use master-slave polling logic.
    */
   protected boolean hasAmsNodeManager() {
+    ensureNodeManagerInitialized();
     return amsNodeManager != null || thriftAmsNodeManager != null;
   }
 
@@ -101,11 +135,22 @@ public class AbstractOptimizerOperator implements 
Serializable {
    * node manager is available or the node list is empty.
    */
   protected List<String> getAmsNodeUrls() {
+    ensureNodeManagerInitialized();
     if (amsNodeManager != null) {
       List<String> urls = amsNodeManager.getAllAmsUrls();
       if (!urls.isEmpty()) {
         return urls;
       }
+      long now = System.currentTimeMillis();
+      if (now - lastEmptyNodesWarnTime > TimeUnit.MINUTES.toMillis(1)) {
+        lastEmptyNodesWarnTime = now;
+        LOG.warn(
+            "Optimizer is configured for master-slave mode but no AMS nodes 
were discovered "
+                + "from ZooKeeper nodes path. Falling back to the configured 
URL [{}] (master only). "
+                + "Ensure that all AMS nodes have 'ha.use-master-slave-mode: 
true' configured so "
+                + "they register themselves to ZooKeeper and can be discovered 
by optimizers.",
+            getConfig().getAmsUrl());
+      }
     }
     if (thriftAmsNodeManager != null) {
       return thriftAmsNodeManager.getAllAmsUrls();
@@ -174,10 +219,6 @@ public class AbstractOptimizerOperator implements 
Serializable {
    */
   protected <T> T callAuthenticatedAms(String amsUrl, 
AmsAuthenticatedCallOperation<T> operation)
       throws TException {
-    // Maximum retry time window for auth errors in master-slave mode (30 
seconds)
-    long maxAuthRetryTimeWindow = TimeUnit.SECONDS.toMillis(30);
-    Long firstAuthErrorTime = null;
-
     // Per-node retry budget: in master-slave mode, limit consecutive 
shouldRetryLater retries so
     // that a permanently unreachable node does not block the multi-node 
polling loop indefinitely.
     int consecutiveRetries = 0;
@@ -190,50 +231,42 @@ public class AbstractOptimizerOperator implements 
Serializable {
         } catch (Throwable t) {
           if (t instanceof AmoroException
               && ErrorCodes.PLUGIN_RETRY_AUTH_ERROR_CODE == ((AmoroException) 
(t)).getErrorCode()) {
-            // In master-slave mode, the slave node may not have completed 
optimizer
-            // auto-registration
-            // yet, so we should retry within a time window before resetting 
the token
             boolean isMasterSlaveMode = config.isMasterSlaveMode() && 
hasAmsNodeManager();
-            long currentTime = System.currentTimeMillis();
 
             if (isMasterSlaveMode) {
-              if (firstAuthErrorTime == null) {
-                // First auth error, record the time
-                firstAuthErrorTime = currentTime;
-              }
-
-              long elapsedTime = currentTime - firstAuthErrorTime;
-              if (elapsedTime < maxAuthRetryTimeWindow) {
-                // Still within retry time window, retry after waiting
-                LOG.warn(
-                    "Got an authorization error while calling ams {} in 
master-slave mode (elapsed: {}ms/{}ms). "
-                        + "This may be because the slave node hasn't completed 
optimizer auto-registration yet. "
-                        + "Will retry after waiting.",
-                    amsUrl,
-                    elapsedTime,
-                    maxAuthRetryTimeWindow,
-                    t);
-                waitAShortTime();
-              } else {
-                // Exceeded retry time window, reset token
-                LOG.error(
-                    "Got authorization errors from ams {} in master-slave mode 
for {}ms, exceeded retry time window ({}ms). "
-                        + "Reset token and wait for a new one",
-                    amsUrl,
-                    elapsedTime,
-                    maxAuthRetryTimeWindow,
-                    t);
-                resetToken(token);
-                firstAuthErrorTime = null;
+              // In master-slave mode, fail fast and let the multi-node 
polling loop in
+              // OptimizerExecutor move on to the next node immediately.
+              //
+              // The root cause of auth failures on a slave is usually that 
the slave has not yet
+              // synced the optimizer registration from the database. Blocking 
here for 30 seconds
+              // and then resetting the GLOBAL token (which is also used by 
the master and other
+              // healthy nodes) would:
+              //   1. Block the for-loop from polling other nodes for 30+ 
seconds.
+              //   2. Re-register with master → get new token → slave still 
fails → infinite loop.
+              //
+              // Instead, throw immediately so pollTask() returns, the 
for-loop advances to the
+              // next node, and the slave is retried on the NEXT poll round 
(basePollInterval
+              // later).
+              // The global token is left intact so master/other nodes 
continue to work normally.
+              LOG.warn(
+                  "Optimizer executor got authorization error from AMS {} in 
master-slave mode. "
+                      + "The slave node may not have synced the optimizer 
registration yet. "
+                      + "Skipping this node for this round; will retry on the 
next poll cycle.",
+                  amsUrl,
+                  t);
+              if (t instanceof TException) {
+                throw (TException) t;
               }
+              throw new TException("Auth error for slave " + amsUrl, t);
             } else {
-              // Non-master-slave mode, reset token immediately
+              // Single-node mode: reset token and wait for OptimizerToucher 
to re-register.
               LOG.error(
-                  "Got a authorization error while calling ams {}, reset token 
and wait for a new one",
+                  "Got authorization error while calling AMS {} (token: {} is 
now invalid). "
+                      + "Token reset; will re-register on the next heartbeat 
cycle.",
                   amsUrl,
+                  token,
                   t);
               resetToken(token);
-              firstAuthErrorTime = null;
             }
           } else if (shouldReturnNull(t)) {
             return null;
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AmsNodeManager.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AmsNodeManager.java
index 94af2ca13..573e0cd87 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AmsNodeManager.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AmsNodeManager.java
@@ -46,7 +46,7 @@ public class AmsNodeManager implements Serializable {
   private final AtomicReference<List<String>> amsUrls =
       new AtomicReference<>(Collections.emptyList());
   private volatile long lastRefreshTime = 0;
-  private transient Object refreshLock;
+  private transient volatile Object refreshLock;
 
   public AmsNodeManager(String amsUrl) {
     Matcher m = ZOOKEEPER_PATTERN.matcher(amsUrl);
@@ -59,6 +59,17 @@ public class AmsNodeManager implements Serializable {
     refreshNodes();
   }
 
+  private Object getRefreshLock() {
+    if (refreshLock == null) {
+      synchronized (this) {
+        if (refreshLock == null) {
+          refreshLock = new Object();
+        }
+      }
+    }
+    return refreshLock;
+  }
+
   /** Get all available AMS URLs. */
   public List<String> getAllAmsUrls() {
     refreshNodesIfNeeded();
@@ -67,7 +78,7 @@ public class AmsNodeManager implements Serializable {
 
   /** Manually refresh node list from ZooKeeper. */
   public void refreshNodes() {
-    synchronized (refreshLock) {
+    synchronized (getRefreshLock()) {
       refreshNodesInternal();
       lastRefreshTime = System.currentTimeMillis();
     }
@@ -75,12 +86,11 @@ public class AmsNodeManager implements Serializable {
 
   /** Refresh node list from ZooKeeper if needed. */
   private void refreshNodesIfNeeded() {
-    long now = System.currentTimeMillis();
-    if (now - lastRefreshTime > REFRESH_INTERVAL_MS) {
-      synchronized (refreshLock) {
-        if (now - lastRefreshTime > REFRESH_INTERVAL_MS) {
+    if (System.currentTimeMillis() - lastRefreshTime > REFRESH_INTERVAL_MS) {
+      synchronized (getRefreshLock()) {
+        if (System.currentTimeMillis() - lastRefreshTime > 
REFRESH_INTERVAL_MS) {
           refreshNodesInternal();
-          lastRefreshTime = now;
+          lastRefreshTime = System.currentTimeMillis();
         }
       }
     }
@@ -89,24 +99,41 @@ public class AmsNodeManager implements Serializable {
   /** Refresh node list from ZooKeeper (internal implementation). */
   private void refreshNodesInternal() {
     try {
-      LOG.info("Refreshing nodes from {}", zkServerAddress);
+      LOG.debug("Refreshing AMS nodes from ZooKeeper: {}", zkServerAddress);
       List<String> nodeUrls = new ArrayList<>();
       List<AmsServerInfo> amsServerInfos = 
AmsThriftUrl.parseMasterSlaveAmsNodes(zkServerAddress);
-      LOG.info("Refreshing nodes from {}", amsServerInfos);
       for (AmsServerInfo amsServerInfo : amsServerInfos) {
         nodeUrls.add(buildAmsUrl(amsServerInfo));
       }
 
+      List<String> previousUrls = amsUrls.get();
       if (!nodeUrls.isEmpty()) {
         amsUrls.set(nodeUrls);
-        LOG.info("Refreshed AMS nodes, found {} nodes: {}", nodeUrls.size(), 
nodeUrls);
+        if (!nodeUrls.equals(previousUrls)) {
+          LOG.info(
+              "AMS node list updated: {} -> {} ({} nodes)",
+              previousUrls.isEmpty() ? "(empty)" : previousUrls,
+              nodeUrls,
+              nodeUrls.size());
+        } else {
+          LOG.debug("AMS node list unchanged: {} ({} nodes)", nodeUrls, 
nodeUrls.size());
+        }
       } else {
-        LOG.warn("No AMS nodes found in ZooKeeper");
         amsUrls.set(Collections.emptyList());
+        if (!previousUrls.isEmpty()) {
+          LOG.warn(
+              "All AMS nodes disappeared from ZooKeeper (was: {}), "
+                  + "will fall back to configured URL until nodes re-register",
+              previousUrls);
+        } else {
+          LOG.warn("No AMS nodes found in ZooKeeper path for {}", 
zkServerAddress);
+        }
       }
     } catch (Exception e) {
-      LOG.error("Failed to refresh AMS nodes from ZooKeeper", e);
-      // Keep existing nodes on error
+      LOG.error(
+          "Failed to refresh AMS nodes from ZooKeeper, keeping previous list: 
{}",
+          amsUrls.get(),
+          e);
     }
   }
 
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
index f83da12da..26eda902c 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
@@ -34,6 +34,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -60,21 +61,67 @@ public class OptimizerExecutor extends 
AbstractOptimizerOperator {
     int consecutiveEmptyPolls = 0;
     final int emptyPollThreshold = 5;
     final Random random = new Random();
+    List<String> lastLoggedAmsUrls = Collections.emptyList();
+
+    LOG.info(
+        "Optimizer executor[{}] starting, mode: {}, configured AMS URL: {}",
+        threadId,
+        getConfig().isMasterSlaveMode() ? "master-slave" : "single-node",
+        getConfig().getAmsUrl());
 
     while (isStarted()) {
       List<String> amsUrls = getAmsNodeUrls();
 
+      // Log only when the node list changes, to avoid spamming on every poll 
cycle.
+      if (!amsUrls.equals(lastLoggedAmsUrls)) {
+        LOG.info(
+            "Optimizer executor[{}] AMS node list changed: {} -> {}",
+            threadId,
+            lastLoggedAmsUrls.isEmpty() ? "(none)" : lastLoggedAmsUrls,
+            amsUrls.isEmpty() ? "(none)" : amsUrls);
+        lastLoggedAmsUrls = new ArrayList<>(amsUrls);
+      }
+
+      if (amsUrls.isEmpty()) {
+        // No AMS nodes discovered yet; warn periodically (first time and 
every 12 cycles ≈ 1 min).
+        if (consecutiveEmptyPolls == 0 || consecutiveEmptyPolls % 12 == 0) {
+          LOG.warn(
+              "Optimizer executor[{}] no AMS nodes available (mode: {}, url: 
{}), "
+                  + "will retry in {}ms",
+              threadId,
+              getConfig().isMasterSlaveMode() ? "master-slave" : "single-node",
+              getConfig().getAmsUrl(),
+              basePollInterval);
+        }
+        consecutiveEmptyPolls++;
+        waitAShortTime(basePollInterval);
+        continue;
+      }
+
       // Shuffle to prevent all optimizers from hitting the same AMS node 
simultaneously
       if (amsUrls.size() > 1) {
         Collections.shuffle(amsUrls, random);
+        LOG.debug(
+            "Optimizer executor[{}] poll round starting, shuffled {} nodes: 
{}",
+            threadId,
+            amsUrls.size(),
+            amsUrls);
       }
 
       boolean hasTask = false;
-      for (String amsUrl : amsUrls) {
+      for (int i = 0; i < amsUrls.size(); i++) {
+        String amsUrl = amsUrls.get(i);
         if (!isStarted()) {
           break;
         }
 
+        LOG.debug(
+            "Optimizer executor[{}] polling node [{}/{}]: {}",
+            threadId,
+            i + 1,
+            amsUrls.size(),
+            amsUrl);
+
         OptimizingTask ackTask = null;
         OptimizingTaskResult result = null;
         try {
@@ -107,22 +154,41 @@ public class OptimizerExecutor extends 
AbstractOptimizerOperator {
       }
 
       if (hasTask) {
+        if (consecutiveEmptyPolls >= emptyPollThreshold) {
+          LOG.info(
+              "Optimizer executor[{}] found task after {} consecutive empty 
polls ({}), "
+                  + "resuming normal poll interval",
+              threadId,
+              consecutiveEmptyPolls,
+              amsUrls);
+        }
         consecutiveEmptyPolls = 0;
       } else {
         consecutiveEmptyPolls++;
       }
 
       long waitTime;
-      if (amsUrls.isEmpty()) {
-        waitTime = basePollInterval;
-      } else if (consecutiveEmptyPolls >= emptyPollThreshold) {
+      if (consecutiveEmptyPolls >= emptyPollThreshold) {
         int backoffFactor = consecutiveEmptyPolls - emptyPollThreshold + 1;
         waitTime = Math.min(maxPollInterval, basePollInterval * (1L << 
backoffFactor));
-        LOG.debug(
-            "Optimizer executor[{}] no tasks found for {} consecutive polls, 
using increased interval: {}ms",
-            threadId,
-            consecutiveEmptyPolls,
-            waitTime);
+        if (consecutiveEmptyPolls == emptyPollThreshold) {
+          LOG.info(
+              "Optimizer executor[{}] no tasks found for {} consecutive polls 
across {} node(s) {}, "
+                  + "slowing down poll interval to {}ms (max: {}ms)",
+              threadId,
+              consecutiveEmptyPolls,
+              amsUrls.size(),
+              amsUrls,
+              waitTime,
+              maxPollInterval);
+        } else {
+          LOG.debug(
+              "Optimizer executor[{}] no tasks found for {} consecutive polls, 
"
+                  + "using increased poll interval: {}ms",
+              threadId,
+              consecutiveEmptyPolls,
+              waitTime);
+        }
       } else {
         waitTime = basePollInterval;
       }
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
index 48b8a7e07..9fd24e22e 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
@@ -69,6 +69,13 @@ public class OptimizerToucher extends 
AbstractOptimizerOperator {
 
   private boolean checkToken() {
     if (!tokenIsReady()) {
+      LOG.info(
+          "Registering optimizer to AMS {} (group: {}, mode: {}, threads: {}, 
memory: {}MB) ...",
+          getConfig().getAmsUrl(),
+          getConfig().getGroupName(),
+          getConfig().isMasterSlaveMode() ? "master-slave" : "single-node",
+          getConfig().getExecutionParallel(),
+          getConfig().getMemorySize());
       try {
         String token =
             callAms(
@@ -89,10 +96,18 @@ public class OptimizerToucher extends 
AbstractOptimizerOperator {
         if (tokenChangeListener != null) {
           tokenChangeListener.tokenChange(token);
         }
-        LOG.info("Registered optimizer to ams with token:{}", token);
+        LOG.info(
+            "Successfully registered optimizer to AMS {} (group: {}) with 
token: {}",
+            getConfig().getAmsUrl(),
+            getConfig().getGroupName(),
+            token);
         return true;
       } catch (TException e) {
-        LOG.error("Register optimizer to ams failed", e);
+        LOG.error(
+            "Failed to register optimizer to AMS {} (group: {})",
+            getConfig().getAmsUrl(),
+            getConfig().getGroupName(),
+            e);
         if (e instanceof AmoroException
             && ErrorCodes.FORBIDDEN_ERROR_CODE == ((AmoroException) 
e).getErrorCode()) {
           System.exit(1); // Don't need to try again
@@ -104,20 +119,27 @@ public class OptimizerToucher extends 
AbstractOptimizerOperator {
   }
 
   private void touch() {
+    String currentToken = getToken();
     try {
       callAms(
           client -> {
-            client.touch(getToken());
+            client.touch(currentToken);
             return null;
           });
-      LOG.debug("Optimizer[{}] touch ams", getToken());
+      LOG.debug(
+          "Optimizer touch AMS {} succeeded (token: {})", 
getConfig().getAmsUrl(), currentToken);
     } catch (TException e) {
       if (e instanceof AmoroException
           && ErrorCodes.PLUGIN_RETRY_AUTH_ERROR_CODE == ((AmoroException) 
e).getErrorCode()) {
+        LOG.error(
+            "Got authorization error from AMS {} during touch (token: {} is 
now invalid). "
+                + "Will re-register on the next heartbeat cycle.",
+            getConfig().getAmsUrl(),
+            currentToken,
+            e);
         setToken(null);
-        LOG.error("Got authorization error from ams, try to register later", 
e);
       } else {
-        LOG.error("Touch ams failed", e);
+        LOG.error("Touch AMS {} failed (token: {})", getConfig().getAmsUrl(), 
currentToken, e);
       }
     }
   }
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/ThriftAmsNodeManager.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/ThriftAmsNodeManager.java
index f325d5c6e..f4373f28f 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/ThriftAmsNodeManager.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/ThriftAmsNodeManager.java
@@ -43,7 +43,7 @@ public class ThriftAmsNodeManager implements Serializable {
   private final AtomicReference<List<String>> amsUrls =
       new AtomicReference<>(Collections.emptyList());
   private volatile long lastRefreshTime = 0;
-  private transient Object refreshLock;
+  private transient volatile Object refreshLock;
 
   public ThriftAmsNodeManager(String initialAmsUrl) {
     this.initialAmsUrl = initialAmsUrl;
@@ -70,12 +70,11 @@ public class ThriftAmsNodeManager implements Serializable {
   }
 
   private void refreshNodesIfNeeded() {
-    long now = System.currentTimeMillis();
-    if (now - lastRefreshTime > REFRESH_INTERVAL_MS) {
+    if (System.currentTimeMillis() - lastRefreshTime > REFRESH_INTERVAL_MS) {
       synchronized (getLock()) {
-        if (now - lastRefreshTime > REFRESH_INTERVAL_MS) {
+        if (System.currentTimeMillis() - lastRefreshTime > 
REFRESH_INTERVAL_MS) {
           refreshNodesInternal();
-          lastRefreshTime = now;
+          lastRefreshTime = System.currentTimeMillis();
         }
       }
     }
@@ -103,7 +102,11 @@ public class ThriftAmsNodeManager implements Serializable {
 
   private Object getLock() {
     if (refreshLock == null) {
-      refreshLock = new Object();
+      synchronized (this) {
+        if (refreshLock == null) {
+          refreshLock = new Object();
+        }
+      }
     }
     return refreshLock;
   }
diff --git a/docs/configuration/ams-config.md b/docs/configuration/ams-config.md
index 3285e57e6..c00841dae 100644
--- a/docs/configuration/ams-config.md
+++ b/docs/configuration/ams-config.md
@@ -81,6 +81,7 @@ table td:last-child, table th:last-child { width: 40%; 
word-break: break-all; }
 | ha.node-offline.timeout | 5 min | Timeout duration to determine if a node is 
offline. After this duration, the node's bucket IDs will be reassigned. |
 | ha.session-timeout | 30 s | The Zookeeper session timeout in milliseconds. |
 | ha.type | zk | High availability implementation type: zk or database. |
+| ha.use-master-slave-mode | false | This setting controls whether to enable 
the AMS horizontal scaling feature, which is currently under development and 
testing. |
 | ha.zookeeper-address |  | The Zookeeper address used for high availability. |
 | ha.zookeeper-auth-keytab |  | The Zookeeper authentication keytab file path 
when auth type is KERBEROS. |
 | ha.zookeeper-auth-principal |  | The Zookeeper authentication principal when 
auth type is KERBEROS. |
@@ -145,7 +146,6 @@ table td:last-child, table th:last-child { width: 40%; 
word-break: break-all; }
 | thrift-server.selector-thread-count | 2 | The number of selector threads for 
the Thrift server. |
 | thrift-server.table-service.bind-port | 1260 | Port that the table service 
thrift server is bound to. |
 | thrift-server.table-service.worker-thread-count | 20 | The number of worker 
threads for the Thrift server. |
-| use-master-slave-mode | false | This setting controls whether to enable the 
AMS horizontal scaling feature, which is currently under development and 
testing. |
 
 ## RBAC Example
 

Reply via email to