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

tkhurana pushed a commit to branch PHOENIX-7562-feature-new
in repository https://gitbox.apache.org/repos/asf/phoenix.git


The following commit(s) were added to refs/heads/PHOENIX-7562-feature-new by 
this push:
     new 91db1394c6 PHOENIX-7566 HAGroupStore control plane: znode-recreation 
events, client retention, admin contracts (#2587)
91db1394c6 is described below

commit 91db1394c69803c57a645bb2ca199dc1bf84d0c6
Author: ritegarg <[email protected]>
AuthorDate: Tue Jul 28 09:20:18 2026 -0700

    PHOENIX-7566 HAGroupStore control plane: znode-recreation events, client 
retention, admin contracts (#2587)
    
    Six fixes found while qualifying the consistent-HA control plane on a 
two-cluster
    setup.
    
    Deliver events for recreated znodes. Both caches de-duplicated on 
ZooKeeper's data
    version alone, which resets to 0 when a znode is deleted and recreated, so 
the new
    incarnation looked stale and was dropped. Track (czxid, version) instead: a 
changed
    czxid is a new incarnation and is accepted at version 0. A recreated local 
record is
    redelivered once even when its state is unchanged, so ABORT_TO_STANDBY,
    ABORT_TO_ACTIVE_IN_SYNC, and ABORT_TO_ACTIVE_NOT_IN_SYNC still drive their
    completion transitions. The peer watcher's forced reconnect redelivery is 
unchanged,
    and closeConnection() clears the watermark so a new peer ensemble starts 
clean.
    
    Keep the cached client through a transient local ZooKeeper outage. 
SUSPENDED/LOST
    marked the client unhealthy, and the next getInstanceForZkUrl() built a 
replacement
    and closed the old one. Subscriptions live per instance and failover 
management wires
    its listeners once per HA group, so every internal and application listener 
stayed on
    the discarded object while the replacement came up with no subscribers. 
Only create a
    client when none is mapped; unhealthy instances still fail closed and 
recover on
    CONNECTION_RECONNECTED.
    
    Validate the admin tool's HA policy against HighAvailabilityPolicy before 
dry-run,
    reads, or writes, and store the canonical enum name; unsupported values 
reached
    persistence and only failed later at valueOf. Make create strictly 
create-only:
    an existing HA-group name now returns RET_VALIDATION_ERROR instead of 
reporting
    success for a conflicting request.
    
    Gate failover and abort success on both clusters converging to stable local 
STANDBY
    and peer ACTIVE_IN_SYNC or ACTIVE_NOT_IN_SYNC. Polling accepted the local 
state alone,
    so abort reported success while the peer was still in 
ABORT_TO_ACTIVE_IN_SYNC. Roles
    alone are insufficient because the abort states already map to the final 
roles.
    
    Correct CLI guidance: malformed ZooKeeper URLs no longer suggest --force, 
which cannot
    bypass identity-key validation; top-level help/-h/--help exit 0; and a 
version-only
    update explains that a configuration field is also required.
    
    Validate that the client initialization timeout and system-table sync 
interval are
    positive before any admin, cache, executor, or cache-map entry is created, 
naming the
    key and value. Peer-cache retry and legacy CRR reconciliation keep <= 0 as 
disable.
    HAGroupStoreManager translates the resulting IllegalArgumentException to 
IOException
    so a misconfiguration cannot abort a RegionServer through a coprocessor 
hook.
    
    Co-authored-by: Cursor
    
    Co-authored-by: Cursor <[email protected]>
---
 .../apache/phoenix/jdbc/HAGroupStoreCacheUtil.java |  5 ++
 .../apache/phoenix/jdbc/HAGroupStoreClient.java    | 63 +++++++++-----
 .../apache/phoenix/jdbc/HAGroupStoreManager.java   |  8 +-
 .../apache/phoenix/jdbc/PeerClusterWatcher.java    | 15 ++--
 .../apache/phoenix/jdbc/PhoenixHAAdminTool.java    | 97 ++++++++++++++--------
 .../apache/phoenix/jdbc/HAGroupStoreClientIT.java  | 42 ++++++++++
 .../apache/phoenix/jdbc/HAGroupStoreMetricsIT.java |  7 +-
 .../apache/phoenix/jdbc/PhoenixHAAdminToolIT.java  | 55 +++++++++---
 .../jdbc/HAGroupStoreClientConfigurationTest.java  | 55 ++++++++++++
 .../phoenix/jdbc/PeerClusterWatcherTest.java       | 45 ++++++++++
 .../phoenix/jdbc/PhoenixHAAdminToolTest.java       | 85 +++++++++++++++++++
 11 files changed, 399 insertions(+), 78 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreCacheUtil.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreCacheUtil.java
index 2fad2392d8..8080c1974d 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreCacheUtil.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreCacheUtil.java
@@ -56,6 +56,11 @@ final class HAGroupStoreCacheUtil {
     return cache == null ? Pair.of(null, null) : 
recordAndStat(cache.getCurrentData(path));
   }
 
+  /** A different creation zxid is a new znode incarnation; versions order 
updates within it. */
+  static boolean isNewerRevision(Stat candidate, long lastCzxid, int 
lastVersion) {
+    return candidate.getCzxid() != lastCzxid || candidate.getVersion() > 
lastVersion;
+  }
+
   /**
    * Build and start a cache, waiting up to {@code timeoutMs} for the initial 
load. The supplied
    * {@code listener} receives every cache event; this method releases its 
initial-load latch on the
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreClient.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreClient.java
index 82c8460f78..0e118f6a10 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreClient.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreClient.java
@@ -141,6 +141,9 @@ public class HAGroupStoreClient implements Closeable {
   private final long rotationTimeMs;
   // State tracking for transition detection
   private volatile HAGroupState lastKnownLocalState;
+  // ZooKeeper revision of the last local state processed.
+  private long lastDeliveredLocalCzxid = -1L;
+  private int lastDeliveredLocalVersion = -1;
   // Last peer state delivered to subscribers; used only to populate the 
"from" state in PEER
   // notifications. Written from the peer watcher's single delivery thread.
   private volatile HAGroupState lastKnownPeerState;
@@ -173,8 +176,9 @@ public class HAGroupStoreClient implements Closeable {
   }
 
   /**
-   * Creates/gets an instance of HAGroupStoreClient. Can return null instance 
if unable to
-   * initialize.
+   * Creates/gets an instance of HAGroupStoreClient. Can return null if 
initial construction fails.
+   * A registered instance remains cached while temporarily unhealthy and 
fails operations closed
+   * until Curator reconnects; explicit {@link #close()} removes it.
    * @param conf        configuration
    * @param haGroupName name of the HA group. Only specified HA group is 
tracked.
    * @param zkUrl       zkUrl to use for the client. Prefer providing this 
parameter to avoid the
@@ -184,17 +188,16 @@ public class HAGroupStoreClient implements Closeable {
   public static HAGroupStoreClient getInstanceForZkUrl(Configuration conf, 
String haGroupName,
     String zkUrl) {
     Preconditions.checkNotNull(haGroupName, "haGroupName cannot be null");
+    validateConfiguration(conf);
     String localZkUrl = Objects.toString(zkUrl, getLocalZkUrl(conf));
     Preconditions.checkNotNull(localZkUrl, "zkUrl cannot be null");
     HAGroupStoreClient result =
       instances.getOrDefault(localZkUrl, new 
ConcurrentHashMap<>()).getOrDefault(haGroupName, null);
-    if (result == null || !result.isHealthy) {
-      HAGroupStoreClient replaced = null;
+    if (result == null) {
       synchronized (HAGroupStoreClient.class) {
         result = instances.getOrDefault(localZkUrl, new ConcurrentHashMap<>())
           .getOrDefault(haGroupName, null);
-        if (result == null || !result.isHealthy) {
-          HAGroupStoreClient stale = result;
+        if (result == null) {
           result = new HAGroupStoreClient(conf, null, haGroupName, zkUrl);
           if (!result.isHealthy) {
             result.close();
@@ -209,20 +212,29 @@ public class HAGroupStoreClient implements Closeable {
               v.put(haGroupName, created);
               return v;
             });
-            replaced = stale;
           }
         }
       }
-      // Reclaim the replaced unhealthy client's threads and caches. Outside 
the monitor because
-      // close() can block; safe because deregisterFromInstances() is 
value-based and will not
-      // remove the replacement we just registered.
-      if (replaced != null) {
-        replaced.close();
-      }
     }
     return result;
   }
 
+  @VisibleForTesting
+  static void validateConfiguration(Configuration conf) {
+    requirePositive(conf, 
PHOENIX_HA_GROUP_STORE_CLIENT_INITIALIZATION_TIMEOUT_MS,
+      DEFAULT_HA_GROUP_STORE_CLIENT_INITIALIZATION_TIMEOUT_MS);
+    requirePositive(conf, HA_GROUP_STORE_SYNC_INTERVAL_SECONDS,
+      DEFAULT_HA_GROUP_STORE_SYNC_INTERVAL_SECONDS);
+  }
+
+  private static void requirePositive(Configuration conf, String key, long 
defaultValue) {
+    long value = conf.getLong(key, defaultValue);
+    if (value <= 0) {
+      throw new IllegalArgumentException(
+        key + " must be greater than 0; configured value=" + value);
+    }
+  }
+
   /**
    * Get the list of HAGroupNames from system table. We can also get the list 
of HAGroupNames from
    * the system table by providing the zkUrl in where clause, but we need to 
match the formatted
@@ -702,8 +714,8 @@ public class HAGroupStoreClient implements Closeable {
       return true;
     } catch (Exception e) {
       metricsSource.incrementSystemTableSyncFailedCount();
-      LOGGER.error("Failed to update system table on best effort basis for HA 
group {}", haGroupName,
-        e);
+      LOGGER.error("Failed to update system table on best effort basis for HA 
group {}",
+        haGroupName, e);
       return false;
     }
   }
@@ -1149,11 +1161,10 @@ public class HAGroupStoreClient implements Closeable {
   }
 
   /**
-   * Remove this instance from the static {@link #instances} map. Idempotent. 
Uses value-based
-   * remove so that, if a concurrent {@link #getInstanceForZkUrl} has already 
swapped in a fresh
-   * replacement, the replacement is preserved. The outer-CHM {@code 
computeIfPresent} below pairs
-   * with the {@code compute} in {@link #getInstanceForZkUrl} to serialize 
bucket creation/removal
-   * on the same key.
+   * Remove this instance from the static {@link #instances} map. Idempotent. 
Value-based removal
+   * preserves a fresh instance created concurrently after this one was 
deregistered. The outer-CHM
+   * {@code computeIfPresent} below pairs with the {@code compute} in {@link 
#getInstanceForZkUrl}
+   * to serialize bucket creation/removal on the same key.
    */
   private void deregisterFromInstances() {
     final String key = (this.zkUrl != null) ? this.zkUrl : 
getLocalZkUrl(this.conf);
@@ -1485,6 +1496,16 @@ public class HAGroupStoreClient implements Closeable {
   private void handleLocalStateChange(HAGroupStoreRecord newRecord, Stat 
newStat) {
     HAGroupState newState = newRecord.getHAGroupState();
     synchronized (localTransitionLock) {
+      if (
+        !HAGroupStoreCacheUtil.isNewerRevision(newStat, 
lastDeliveredLocalCzxid,
+          lastDeliveredLocalVersion)
+      ) {
+        return;
+      }
+      boolean recreated =
+        lastDeliveredLocalCzxid >= 0 && newStat.getCzxid() != 
lastDeliveredLocalCzxid;
+      lastDeliveredLocalCzxid = newStat.getCzxid();
+      lastDeliveredLocalVersion = newStat.getVersion();
       HAGroupState oldState = lastKnownLocalState;
       lastKnownLocalState = newState;
       metricsSource.setCurrentLocalState(newState);
@@ -1509,7 +1530,7 @@ public class HAGroupStoreClient implements Closeable {
           return;
         }
       }
-      if (oldState == null || !oldState.equals(newState)) {
+      if (recreated || oldState == null || !oldState.equals(newState)) {
         // from = the effective state subscribers last saw, derived from the 
prior raw state and the
         // overlay flag (unchanged on this path) -- not the bare raw oldState. 
This is what makes a
         // failover while degraded read DEGRADED_STANDBY -> STANDBY_TO_ACTIVE.
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreManager.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreManager.java
index 4240748fc0..e579d109c1 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreManager.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HAGroupStoreManager.java
@@ -575,8 +575,12 @@ public class HAGroupStoreManager {
    * @throws IOException when HAGroupStoreClient is not initialized
    */
   private HAGroupStoreClient getHAGroupStoreClient(final String haGroupName) 
throws IOException {
-    HAGroupStoreClient haGroupStoreClient =
-      HAGroupStoreClient.getInstanceForZkUrl(conf, haGroupName, zkUrl);
+    HAGroupStoreClient haGroupStoreClient;
+    try {
+      haGroupStoreClient = HAGroupStoreClient.getInstanceForZkUrl(conf, 
haGroupName, zkUrl);
+    } catch (IllegalArgumentException e) {
+      throw new IOException("Invalid HAGroupStoreClient configuration: " + 
e.getMessage(), e);
+    }
     if (haGroupStoreClient == null) {
       throw new IOException("HAGroupStoreClient is not initialized for HA 
group: " + haGroupName);
     }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PeerClusterWatcher.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PeerClusterWatcher.java
index 8be827d31c..ef21b570d3 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PeerClusterWatcher.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PeerClusterWatcher.java
@@ -102,6 +102,7 @@ final class PeerClusterWatcher implements Closeable {
   private String peerZkUrl; // desired peer; blank = none
   private PhoenixHAAdmin admin;
   private PathChildrenCache cache;
+  private long lastDeliveredCzxid = -1L;
   private int lastDeliveredVersion = -1;
   private long peerCacheRetryAttempts = 0L;
   private volatile boolean watcherClosed = false;
@@ -342,25 +343,28 @@ final class PeerClusterWatcher implements Closeable {
     // no-ops if empty.
     Pair<HAGroupStoreRecord, Stat> snapshot;
     synchronized (stateLock) {
-      lastDeliveredVersion = -1; // bypass the de-dup check so the forced 
redelivery is not skipped
       snapshot = HAGroupStoreCacheUtil.recordAndStatAt(cache, 
toPath(haGroupName));
     }
     setVisible();
     deliver(snapshot, true);
   }
 
-  private void deliver(Pair<HAGroupStoreRecord, Stat> recordAndStat, boolean 
forced) {
+  @VisibleForTesting
+  void deliver(Pair<HAGroupStoreRecord, Stat> recordAndStat, boolean forced) {
     HAGroupStoreRecord record = recordAndStat.getLeft();
     if (record == null || !Objects.equals(record.getHaGroupName(), 
haGroupName)) {
       return;
     }
     Stat stat = recordAndStat.getRight();
     synchronized (stateLock) {
-      int version = stat != null ? stat.getVersion() : -1;
-      if (!forced && version <= lastDeliveredVersion) {
+      if (
+        !forced
+          && !HAGroupStoreCacheUtil.isNewerRevision(stat, lastDeliveredCzxid, 
lastDeliveredVersion)
+      ) {
         return; // duplicate or stale peer event
       }
-      lastDeliveredVersion = version;
+      lastDeliveredCzxid = stat.getCzxid();
+      lastDeliveredVersion = stat.getVersion();
     }
     listener.onPeerStateChanged(record, stat);
   }
@@ -407,6 +411,7 @@ final class PeerClusterWatcher implements Closeable {
       cache = null;
       a = admin;
       admin = null;
+      lastDeliveredCzxid = -1L;
       lastDeliveredVersion = -1;
     }
     closeCacheQuietly(c);
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixHAAdminTool.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixHAAdminTool.java
index 7fbd9d5db5..f295117af8 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixHAAdminTool.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixHAAdminTool.java
@@ -42,6 +42,7 @@ import java.text.SimpleDateFormat;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -176,6 +177,13 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
       printUsage();
       return RET_ARGUMENT_ERROR;
     }
+    if (
+      args.length == 1
+        && ("help".equalsIgnoreCase(args[0]) || "-h".equals(args[0]) || 
"--help".equals(args[0]))
+    ) {
+      printUsage();
+      return RET_SUCCESS;
+    }
 
     String command = args[0].toLowerCase();
     String[] commandArgs = Arrays.copyOfRange(args, 1, args.length);
@@ -211,7 +219,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
         // Required: --ha-group
         return executeGetClusterRoleRecord(commandArgs);
       case CMD_CREATE:
-        // Creates a new HA group entry in SYSTEM.HA_GROUP (idempotent)
+        // Creates a new HA group entry in SYSTEM.HA_GROUP
         // Required: --ha-group, --policy, --zk-url-1, --cluster-url-1, 
--cluster-role-1,
         // --zk-url-2, --cluster-url-2, --cluster-role-2
         // Optional: --hdfs-url-1, --hdfs-url-2, --admin-version, --dry-run
@@ -300,7 +308,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
       }
 
       // Parse configuration fields
-      String policy = cmdLine.getOptionValue(POLICY_OPT.getOpt());
+      String policy = parsePolicy(cmdLine.getOptionValue(POLICY_OPT.getOpt()));
       String state = cmdLine.getOptionValue(STATE_OPT.getOpt());
       String clusterUrl = cmdLine.getOptionValue(CLUSTER_URL_OPT.getOpt());
       String peerClusterUrl = 
cmdLine.getOptionValue(PEER_CLUSTER_URL_OPT.getOpt());
@@ -326,7 +334,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
           && peerZkUrl == null && protocolVersion == null && lastSyncTimeStr 
== null
           && hdfsUrl == null && peerHdfsUrl == null
       ) {
-        throw new IllegalArgumentException("Must specify at least one field to 
update");
+        throw new IllegalArgumentException("Must specify at least one 
configuration field in "
+          + "addition to --admin-version/--auto-increment-version");
       }
 
       // Validate URLs against the registry type the read path normalizes them 
with (ZK quorum vs
@@ -541,7 +550,10 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
     if (invalid.length() == 0) {
       return;
     }
-    if (force) {
+    if (registryType == ClusterRoleRecord.RegistryType.ZK) {
+      throw new IllegalArgumentException("Malformed ZooKeeper URL(s): " + 
invalid
+        + ". ZooKeeper URLs are identity keys and must be valid; --force 
cannot bypass this validation.");
+    } else if (force) {
       LOG.warn("Storing malformed URL(s) due to --force: {}", invalid);
     } else {
       throw new IllegalArgumentException(
@@ -676,8 +688,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
         // Poll for state transitions
         System.out
           .println("\n[Step 4] Monitoring state transitions (timeout: " + 
timeoutSeconds + "s)...");
-        boolean transitionComplete =
-          pollForStateTransition(manager, haGroupName, finalExpectedState, 
timeoutSeconds);
+        boolean transitionComplete = pollForStateTransition(manager, 
haGroupName, timeoutSeconds);
 
         if (transitionComplete) {
           System.out.println("\n✓ Failover completed successfully");
@@ -689,8 +700,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
           return RET_SUCCESS;
         } else {
           System.err.println("\n⚠ Failover transition incomplete");
-          System.err.println("  The failover was initiated but did not 
complete within "
-            + timeoutSeconds + " seconds.");
+          System.err.println("  The local failover may have completed, but the 
cluster pair did "
+            + "not converge within " + timeoutSeconds + " seconds.");
           printFailoverRecoveryGuidance(haGroupName);
           return RET_UPDATE_ERROR;
         }
@@ -736,8 +747,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
    * </li>
    * <li>Poll for completion via pollForStateTransition():
    * <ul>
-   * <li>Polls every 2 seconds, waiting for STANDBY state</li>
-   * <li>Returns true if reached within timeout, false otherwise</li>
+   * <li>Polls every 2 seconds for local STANDBY and a stable ACTIVE peer</li>
+   * <li>Returns true if the pair converges within timeout, false 
otherwise</li>
    * </ul>
    * </li>
    * <li>Return RET_SUCCESS if completed, RET_UPDATE_ERROR if timeout</li>
@@ -809,8 +820,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
         // Poll for state transitions
         System.out
           .println("\n[Step 4] Monitoring state transitions (timeout: " + 
timeoutSeconds + "s)...");
-        boolean transitionComplete =
-          pollForStateTransition(manager, haGroupName, HAGroupState.STANDBY, 
timeoutSeconds);
+        boolean transitionComplete = pollForStateTransition(manager, 
haGroupName, timeoutSeconds);
 
         if (transitionComplete) {
           System.out.println("\n✓ Failover abort completed successfully");
@@ -822,9 +832,10 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
           return RET_SUCCESS;
         } else {
           System.err.println("\n⚠ Abort transition incomplete");
-          System.err.println("  The abort was initiated but did not complete 
within "
-            + timeoutSeconds + " seconds.");
-          printFailoverRecoveryGuidance(haGroupName);
+          System.err.println("  The local abort may have completed, but the 
cluster pair did not "
+            + "converge within " + timeoutSeconds + " seconds.");
+          System.err.println("  Inspect the pair with get-cluster-role-record 
-g " + haGroupName
+            + " before retrying or forcing state.");
           return RET_UPDATE_ERROR;
         }
       }
@@ -899,10 +910,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
   }
 
   /**
-   * Creates a new HA group entry in SYSTEM.HA_GROUP. Idempotent: if the group 
already exists,
-   * prints a skip message and returns success without modifying the existing 
row. The ZK znode is
-   * initialized automatically on first access by HAGroupStoreClient. Run the 
same command on both
-   * clusters.
+   * Creates a new HA group entry in SYSTEM.HA_GROUP. Existing groups are 
rejected and must be
+   * inspected with get and changed through update where supported.
    */
   private int executeCreate(String[] args) throws Exception {
     try {
@@ -914,7 +923,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
       }
 
       String haGroupName = getRequiredOption(cmdLine, HA_GROUP_OPT, "HA group 
name");
-      String policy = getRequiredOption(cmdLine, POLICY_OPT, "policy");
+      String policy = parsePolicy(getRequiredOption(cmdLine, POLICY_OPT, 
"policy"));
       String zkUrl1 = getRequiredOption(cmdLine, ZK_URL_1_OPT, "ZK URL for 
cluster 1");
       String clusterUrl1 =
         getRequiredOption(cmdLine, CLUSTER_URL_1_OPT, "cluster URL for cluster 
1");
@@ -950,9 +959,9 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
       String localZkUrl = getLocalZkUrl(getConf());
 
       if (haGroupExistsInSystemTable(haGroupName, localZkUrl)) {
-        System.out.println("HA group '" + haGroupName
-          + "' already exists in SYSTEM.HA_GROUP. Skipping creation. Use 
update command to modify it.");
-        return RET_SUCCESS;
+        System.err.println("HA group '" + haGroupName
+          + "' already exists in SYSTEM.HA_GROUP. Use get to inspect it and 
update for supported changes.");
+        return RET_VALIDATION_ERROR;
       }
 
       System.out.println("\n=== HA Group to Create ===\n");
@@ -1132,21 +1141,21 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
   }
 
   /**
-   * Poll for state transition completion with timeout
+   * Poll for stable local STANDBY and peer ACTIVE convergence with timeout.
    * @param manager        HAGroupStoreManager instance
    * @param haGroupName    HA group name
-   * @param finalState     Expected final state
    * @param timeoutSeconds Timeout in seconds
    * @return true if transition completed, false if timed out
    */
   private boolean pollForStateTransition(HAGroupStoreManager manager, String 
haGroupName,
-    HAGroupState finalState, int timeoutSeconds) {
+    int timeoutSeconds) {
 
     long startTime = System.currentTimeMillis();
     long timeoutMillis = timeoutSeconds * 1000L;
     long pollIntervalMillis = 2000; // Poll every 2 seconds
 
     HAGroupState lastSeenState = null;
+    HAGroupState lastSeenPeerState = null;
     int dots = 0;
 
     try {
@@ -1162,13 +1171,14 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
           HAGroupState currentState = recordOpt.get().getHAGroupState();
 
           // Print state change
-          if (lastSeenState != currentState) {
+          if (lastSeenState != currentState || lastSeenPeerState != peerState) 
{
             if (lastSeenState != null) {
               System.out.println(); // New line after dots
             }
             System.out.println("  Current State: " + currentState);
             System.out.println("  Peer State: " + peerState);
             lastSeenState = currentState;
+            lastSeenPeerState = peerState;
             dots = 0;
           } else {
             // Print progress dots
@@ -1180,8 +1190,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
             }
           }
 
-          // Check if we reached the final state
-          if (currentState == finalState) {
+          if (isStableFailoverPair(currentState, peerState)) {
             if (dots > 0) {
               System.out.println(); // New line after dots
             }
@@ -1200,7 +1209,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
       }
       System.err.println("  ✗ Timeout after " + timeoutSeconds + " seconds");
       if (lastSeenState != null) {
-        System.err.println("  Last seen state: " + lastSeenState);
+        System.err.println("  Last seen local state: " + lastSeenState);
+        System.err.println("  Last seen peer state: " + lastSeenPeerState);
       }
       return false;
 
@@ -1213,6 +1223,12 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
     }
   }
 
+  @VisibleForTesting
+  static boolean isStableFailoverPair(HAGroupState localState, HAGroupState 
peerState) {
+    return localState == HAGroupState.STANDBY
+      && (peerState == HAGroupState.ACTIVE_IN_SYNC || peerState == 
HAGroupState.ACTIVE_NOT_IN_SYNC);
+  }
+
   /**
    * Print operator guidance shown when a failover / abort does not converge 
within the timeout.
    * Post-partition states (e.g. active ACTIVE_IN_SYNC_TO_STANDBY with peer 
STANDBY) are ambiguous
@@ -1668,6 +1684,19 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
     }
   }
 
+  private static String parsePolicy(String policy) {
+    if (policy == null) {
+      return null;
+    }
+    try {
+      return 
HighAvailabilityPolicy.valueOf(policy.toUpperCase(Locale.ROOT)).name();
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("Invalid HA policy: " + policy + 
"\nSupported policies: "
+        + Arrays.stream(HighAvailabilityPolicy.values()).map(Enum::name)
+          .collect(Collectors.joining(", ")));
+    }
+  }
+
   /**
    * Parse long from string
    */
@@ -1694,8 +1723,7 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
     System.out.println("Usage: phoenix-consistentha-admin-tool <command> 
[options]");
     System.out.println();
     System.out.println("Commands:");
-    System.out
-      .println("  create                  Create a new HA group in 
SYSTEM.HA_GROUP (idempotent)");
+    System.out.println("  create                  Create a new HA group in 
SYSTEM.HA_GROUP");
     System.out.println("  update                  Update HA group 
configuration");
     System.out.println("  get                     Show HA group 
configuration");
     System.out.println("  list                    List all HA groups");
@@ -1730,9 +1758,8 @@ public class PhoenixHAAdminTool extends Configured 
implements Tool {
     System.out.println();
     System.out.println("Description:");
     System.out.println("  Creates a new HA group entry in SYSTEM.HA_GROUP. The 
ZK znode is");
-    System.out.println("  initialized automatically on first access. This 
command is idempotent:");
-    System.out.println("  running it again on an existing HA group prints a 
skip message and");
-    System.out.println("  returns success. Run the same command on both 
clusters.");
+    System.out.println("  initialized automatically on first access. Existing 
names are rejected;");
+    System.out.println("  use get to inspect them and update for supported 
changes.");
     System.out.println();
     System.out.println("REQUIRED:");
     System.out.println("  -g,    --ha-group <name>         HA group name");
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreClientIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreClientIT.java
index 7b8f90dba3..4ea96ed0ba 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreClientIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreClientIT.java
@@ -32,6 +32,7 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
@@ -557,6 +558,10 @@ public class HAGroupStoreClientIT extends HABaseIT {
     createOrUpdateHAGroupStoreRecordOnZookeeper(haAdmin, haGroupName, record);
     HAGroupStoreClient haGroupStoreClient = HAGroupStoreClient
       .getInstanceForZkUrl(CLUSTERS.getHBaseCluster1().getConfiguration(), 
haGroupName, zkUrl);
+    AtomicInteger deliveries = new AtomicInteger();
+    
haGroupStoreClient.subscribeToTargetState(HAGroupStoreRecord.HAGroupState.ACTIVE_NOT_IN_SYNC,
+      ClusterType.LOCAL,
+      (group, from, to, mtime, clusterType, lastSync) -> 
deliveries.incrementAndGet());
 
     Thread.sleep(ZK_CURATOR_EVENT_PROPAGATION_TIMEOUT_MS);
     HAGroupStoreRecord currentRecord = 
haGroupStoreClient.getHAGroupStoreRecord();
@@ -573,10 +578,14 @@ public class HAGroupStoreClientIT extends HABaseIT {
     Field isHealthyField = 
HAGroupStoreClient.class.getDeclaredField("isHealthy");
     isHealthyField.setAccessible(true);
     assertFalse((boolean) isHealthyField.get(haGroupStoreClient));
+    HAGroupStoreClient duringOutage = HAGroupStoreClient
+      .getInstanceForZkUrl(CLUSTERS.getHBaseCluster1().getConfiguration(), 
haGroupName, zkUrl);
 
     // Start ZK on the same port to simulate CONNECTION_RECONNECTED event
     CLUSTERS.getHBaseCluster1().startMiniZKCluster(1, Integer.parseInt(
       
CLUSTERS.getHBaseCluster1().getConfiguration().get("hbase.zookeeper.property.clientPort")));
+    assertSame("factory must retain the subscribed client while local ZK is 
unavailable",
+      haGroupStoreClient, duringOutage);
 
     Thread.sleep(ZK_CURATOR_EVENT_PROPAGATION_TIMEOUT_MS);
     // Check that HAGroupStoreClient instance is back to healthy and provides 
correct response
@@ -585,6 +594,39 @@ public class HAGroupStoreClientIT extends HABaseIT {
       && currentRecord.getHAGroupState() == 
HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC;
     // Check that the HAGroupStoreClient instance is healthy via reflection
     assertTrue((boolean) isHealthyField.get(haGroupStoreClient));
+    record = 
record.withHAGroupState(HAGroupStoreRecord.HAGroupState.ACTIVE_NOT_IN_SYNC);
+    createOrUpdateHAGroupStoreRecordOnZookeeper(haAdmin, haGroupName, record);
+    long deadline = System.currentTimeMillis() + 
ZK_CURATOR_EVENT_PROPAGATION_TIMEOUT_MS * 3;
+    while (deliveries.get() < 1 && System.currentTimeMillis() < deadline) {
+      Thread.sleep(100);
+    }
+    assertEquals("listener registered before the outage must survive 
reconnect", 1,
+      deliveries.get());
+  }
+
+  @Test
+  public void testRecreatedLocalZnodeRedeliversSameAbortState() throws 
Exception {
+    String haGroupName = testName.getMethodName();
+    HAGroupStoreRecord record = new HAGroupStoreRecord("v1.0", haGroupName,
+      HAGroupStoreRecord.HAGroupState.ABORT_TO_ACTIVE_IN_SYNC, 0L,
+      HighAvailabilityPolicy.FAILOVER.toString(), this.peerZKUrl, 
this.masterUrl,
+      this.peerMasterUrl, CLUSTERS.getHdfsUrl1(), CLUSTERS.getHdfsUrl2(), 0L);
+    createOrUpdateHAGroupStoreRecordOnZookeeper(haAdmin, haGroupName, record);
+    HAGroupStoreClient client = HAGroupStoreClient
+      .getInstanceForZkUrl(CLUSTERS.getHBaseCluster1().getConfiguration(), 
haGroupName, zkUrl);
+    AtomicInteger deliveries = new AtomicInteger();
+    
client.subscribeToTargetState(HAGroupStoreRecord.HAGroupState.ABORT_TO_ACTIVE_IN_SYNC,
+      ClusterType.LOCAL,
+      (group, from, to, mtime, clusterType, lastSync) -> 
deliveries.incrementAndGet());
+
+    createOrUpdateHAGroupStoreRecordOnZookeeper(haAdmin, haGroupName, record);
+    Thread.sleep(ZK_CURATOR_EVENT_PROPAGATION_TIMEOUT_MS);
+    assertEquals("same znode incarnation should not redeliver the same state", 
0, deliveries.get());
+
+    haAdmin.deleteHAGroupStoreRecordInZooKeeper(haGroupName);
+    haAdmin.createHAGroupStoreRecordInZooKeeper(record);
+    Thread.sleep(ZK_CURATOR_EVENT_PROPAGATION_TIMEOUT_MS);
+    assertEquals("recreated znode should redeliver the same abort state once", 
1, deliveries.get());
   }
 
   /**
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreMetricsIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreMetricsIT.java
index 80e1f7781a..3c37d83b09 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreMetricsIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreMetricsIT.java
@@ -23,7 +23,6 @@ import static org.apache.phoenix.jdbc.PhoenixHAAdmin.toPath;
 import static 
org.apache.phoenix.query.QueryServices.HA_GROUP_STORE_SYNC_INTERVAL_SECONDS;
 import static 
org.apache.phoenix.replication.reader.ReplicationLogReplayService.PHOENIX_REPLICATION_REPLAY_ENABLED;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
@@ -148,11 +147,11 @@ public class HAGroupStoreMetricsIT extends HABaseIT {
   @Test
   public void testLateInitializationFailureResetsGauges() throws Exception {
     writeLocal(HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC);
-    Configuration invalidConf =
-      new Configuration(CLUSTERS.getHBaseCluster1().getConfiguration());
+    Configuration invalidConf = new 
Configuration(CLUSTERS.getHBaseCluster1().getConfiguration());
     invalidConf.setLong(HA_GROUP_STORE_SYNC_INTERVAL_SECONDS, 0L);
 
-    assertNull(HAGroupStoreClient.getInstanceForZkUrl(invalidConf, group(), 
zkUrl));
+    HAGroupStoreClient failed = new HAGroupStoreClient(invalidConf, null, 
group(), zkUrl);
+    assertThrows(IOException.class, failed::getHAGroupStoreRecord);
 
     HAGroupStoreMetricValues values = metrics();
     assertEquals(1L, values.getLocalCacheHealthStatus());
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolIT.java
index c6d110f55e..9fb69457ea 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolIT.java
@@ -34,6 +34,7 @@ import static 
org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.ZK_URL_2;
 import static org.apache.phoenix.jdbc.PhoenixHAAdmin.toPath;
 import static org.apache.phoenix.jdbc.PhoenixHAAdminTool.RET_ARGUMENT_ERROR;
 import static org.apache.phoenix.jdbc.PhoenixHAAdminTool.RET_SUCCESS;
+import static org.apache.phoenix.jdbc.PhoenixHAAdminTool.RET_VALIDATION_ERROR;
 import static org.apache.phoenix.util.PhoenixRuntime.JDBC_PROTOCOL_SEPARATOR;
 import static org.apache.phoenix.util.PhoenixRuntime.JDBC_PROTOCOL_ZK;
 import static org.junit.Assert.assertEquals;
@@ -984,10 +985,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
     }
   }
 
-  /**
-   * Test that the create command is idempotent: if the HA group already 
exists in SYSTEM.HA_GROUP,
-   * it returns success without modifying the existing row.
-   */
+  /** Existing HA groups are rejected without modifying the stored row. */
   @Test(timeout = 180000)
   public void testCreateCommandAlreadyExists() throws Exception {
     // haGroupName is pre-inserted by @Before
@@ -995,6 +993,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
     assertNotNull("Record should exist before create command", originalRecord);
 
     System.setOut(new PrintStream(STDOUT_CAPTURE));
+    System.setErr(new PrintStream(STDOUT_CAPTURE));
 
     // Run create with different values - these should NOT overwrite the 
existing row
     int ret = ToolRunner.run(adminTool,
@@ -1003,14 +1002,13 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
         CLUSTERS.getMasterAddress2(), "-cr2", "ACTIVE", "-hdfs1", 
CLUSTERS.getHdfsUrl1(), "-hdfs2",
         CLUSTERS.getHdfsUrl2() });
 
-    assertEquals("create command should return success even when group already 
exists", RET_SUCCESS,
-      ret);
+    assertEquals("create command should reject an existing group", 
RET_VALIDATION_ERROR, ret);
 
     String output = STDOUT_CAPTURE.toString();
     LOG.info("Got stdout from create (existing group): 
\n++++++++\n{}++++++++\n", output);
 
-    assertTrue("Output should indicate group already exists and was skipped",
-      output.contains("already exists") && output.contains("Skipping"));
+    assertTrue("Output should direct the operator to inspect and update the 
existing group",
+      output.contains("already exists") && output.contains("get") && 
output.contains("update"));
 
     // Verify the system table row was NOT modified
     SystemTableRecord afterRecord = querySystemTable(haGroupName, 
CLUSTERS.getZkUrl1());
@@ -1025,6 +1023,37 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
       afterRecord.clusterUrl2);
   }
 
+  @Test(timeout = 180000)
+  public void testPolicyIsValidatedBeforeDryRunOrWrite() throws Exception {
+    SystemTableRecord originalRecord = querySystemTable(haGroupName, 
CLUSTERS.getZkUrl1());
+    new HAGroupStoreManager(CLUSTERS.getHBaseCluster1().getConfiguration())
+      .getHAGroupStoreRecord(haGroupName);
+    assertEquals(RET_ARGUMENT_ERROR, ToolRunner.run(adminTool,
+      new String[] { "update", "-g", haGroupName, "-p", "BOGUS", "-av", "-d" 
}));
+
+    String newGroup = haGroupName + "_new";
+    assertEquals(RET_ARGUMENT_ERROR,
+      ToolRunner.run(adminTool,
+        new String[] { "create", "-g", newGroup, "-p", "BOGUS", "-zk1", 
CLUSTERS.getZkUrl1(), "-c1",
+          CLUSTERS.getMasterAddress1(), "-cr1", "ACTIVE", "-zk2", 
CLUSTERS.getZkUrl2(), "-c2",
+          CLUSTERS.getMasterAddress2(), "-cr2", "STANDBY", "-hdfs1", 
CLUSTERS.getHdfsUrl1(),
+          "-hdfs2", CLUSTERS.getHdfsUrl2(), "-d" }));
+
+    assertEquals(originalRecord.version,
+      querySystemTable(haGroupName, CLUSTERS.getZkUrl1()).version);
+    assertTrue(querySystemTable(newGroup, CLUSTERS.getZkUrl1()) == null);
+
+    STDOUT_CAPTURE.reset();
+    System.setOut(new PrintStream(STDOUT_CAPTURE));
+    assertEquals(RET_SUCCESS,
+      ToolRunner.run(adminTool,
+        new String[] { "create", "-g", newGroup, "-p", "failover", "-zk1", 
CLUSTERS.getZkUrl1(),
+          "-c1", CLUSTERS.getMasterAddress1(), "-cr1", "ACTIVE", "-zk2", 
CLUSTERS.getZkUrl2(),
+          "-c2", CLUSTERS.getMasterAddress2(), "-cr2", "STANDBY", "-hdfs1", 
CLUSTERS.getHdfsUrl1(),
+          "-hdfs2", CLUSTERS.getHdfsUrl2(), "-d" }));
+    assertTrue(STDOUT_CAPTURE.toString().contains("FAILOVER"));
+  }
+
   /**
    * Test that the create command dry-run mode shows what would be created 
without writing to
    * SYSTEM.HA_GROUP.
@@ -1129,16 +1158,18 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
     new HAGroupStoreManager(CLUSTERS.getHBaseCluster1().getConfiguration())
       .getHAGroupStoreRecord(haGroupName);
 
-    System.setOut(new PrintStream(STDOUT_CAPTURE));
+    System.setErr(new PrintStream(STDOUT_CAPTURE));
     int ret = ToolRunner.run(adminTool,
       new String[] { "update", "-g", haGroupName, "-pz", MALFORMED_URL, "-av" 
});
     assertEquals("Malformed peer ZK URL should be rejected", 
RET_ARGUMENT_ERROR, ret);
+    assertTrue(STDOUT_CAPTURE.toString().contains("--force cannot bypass"));
 
     STDOUT_CAPTURE.reset();
     int forcedRet = ToolRunner.run(adminTool,
       new String[] { "update", "-g", haGroupName, "-pz", MALFORMED_URL, "-av", 
"-F" });
     assertEquals("Malformed peer ZK URL should be rejected even with --force", 
RET_ARGUMENT_ERROR,
       forcedRet);
+    assertTrue(STDOUT_CAPTURE.toString().contains("--force cannot bypass"));
   }
 
   /**
@@ -1147,7 +1178,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
   @Test(timeout = 180000)
   public void testCreateRejectsMalformedUrl() throws Exception {
     String createGroup = "testCreateBadUrl_" + System.currentTimeMillis();
-    System.setOut(new PrintStream(STDOUT_CAPTURE));
+    System.setErr(new PrintStream(STDOUT_CAPTURE));
 
     int ret = ToolRunner.run(adminTool,
       new String[] { "create", "-g", createGroup, "-p", "FAILOVER", "-zk1", 
CLUSTERS.getZkUrl1(),
@@ -1155,6 +1186,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
         CLUSTERS.getMasterAddress2(), "-cr2", "STANDBY", "-hdfs1", 
CLUSTERS.getHdfsUrl1(), "-hdfs2",
         CLUSTERS.getHdfsUrl2() });
     assertEquals("Malformed cluster-url-1 should be rejected on create", 
RET_ARGUMENT_ERROR, ret);
+    assertTrue(STDOUT_CAPTURE.toString().contains("pass --force"));
 
     assertTrue("No row should be created for a rejected create",
       querySystemTable(createGroup, CLUSTERS.getZkUrl1()) == null);
@@ -1166,7 +1198,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
   @Test(timeout = 180000)
   public void testCreateRejectsMalformedZkUrlEvenWhenForced() throws Exception 
{
     String createGroup = "testCreateBadZkUrl_" + System.currentTimeMillis();
-    System.setOut(new PrintStream(STDOUT_CAPTURE));
+    System.setErr(new PrintStream(STDOUT_CAPTURE));
 
     int ret = ToolRunner.run(adminTool,
       new String[] { "create", "-g", createGroup, "-p", "FAILOVER", "-zk1", 
MALFORMED_URL, "-c1",
@@ -1175,6 +1207,7 @@ public class PhoenixHAAdminToolIT extends HABaseIT {
         CLUSTERS.getHdfsUrl2(), "-F" });
     assertEquals("Malformed zk-url-1 should be rejected even with --force", 
RET_ARGUMENT_ERROR,
       ret);
+    assertTrue(STDOUT_CAPTURE.toString().contains("--force cannot bypass"));
 
     assertTrue("No row should be created for a rejected create",
       querySystemTable(createGroup, CLUSTERS.getZkUrl1()) == null);
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HAGroupStoreClientConfigurationTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HAGroupStoreClientConfigurationTest.java
new file mode 100644
index 0000000000..bd6276ba0b
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HAGroupStoreClientConfigurationTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.phoenix.jdbc;
+
+import static 
org.apache.phoenix.query.QueryServices.HA_GROUP_STORE_PEER_CACHE_RETRY_INTERVAL_SECONDS;
+import static 
org.apache.phoenix.query.QueryServices.HA_GROUP_STORE_SYNC_INTERVAL_SECONDS;
+import static 
org.apache.phoenix.query.QueryServices.PHOENIX_HA_LEGACY_CRR_RECONCILIATION_INTERVAL_SECONDS;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.hadoop.conf.Configuration;
+import org.junit.Test;
+
+public class HAGroupStoreClientConfigurationTest {
+
+  @Test
+  public void testRequiredIntervalsMustBePositive() {
+    
assertInvalid(HAGroupStoreClient.PHOENIX_HA_GROUP_STORE_CLIENT_INITIALIZATION_TIMEOUT_MS,
 0L);
+    
assertInvalid(HAGroupStoreClient.PHOENIX_HA_GROUP_STORE_CLIENT_INITIALIZATION_TIMEOUT_MS,
 -1L);
+    assertInvalid(HA_GROUP_STORE_SYNC_INTERVAL_SECONDS, 0L);
+    assertInvalid(HA_GROUP_STORE_SYNC_INTERVAL_SECONDS, -1L);
+  }
+
+  @Test
+  public void testOptionalIntervalsMayDisableWork() {
+    Configuration conf = new Configuration(false);
+    conf.setLong(HA_GROUP_STORE_PEER_CACHE_RETRY_INTERVAL_SECONDS, 0L);
+    conf.setLong(PHOENIX_HA_LEGACY_CRR_RECONCILIATION_INTERVAL_SECONDS, -1L);
+    HAGroupStoreClient.validateConfiguration(conf);
+  }
+
+  private static void assertInvalid(String key, long value) {
+    Configuration conf = new Configuration(false);
+    conf.setLong(key, value);
+    IllegalArgumentException error = 
assertThrows(IllegalArgumentException.class,
+      () -> HAGroupStoreClient.validateConfiguration(conf));
+    assertTrue(error.getMessage().contains(key));
+    assertTrue(error.getMessage().contains(String.valueOf(value)));
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PeerClusterWatcherTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PeerClusterWatcherTest.java
index f111ff0a58..37ebdc7314 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PeerClusterWatcherTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PeerClusterWatcherTest.java
@@ -18,11 +18,14 @@
 package org.apache.phoenix.jdbc;
 
 import static 
org.apache.phoenix.query.QueryServices.HA_GROUP_STORE_PEER_CACHE_RETRY_INTERVAL_SECONDS;
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.commons.lang3.tuple.Pair;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.zookeeper.data.Stat;
 import org.junit.Test;
@@ -192,4 +195,46 @@ public class PeerClusterWatcherTest {
       watcher.close();
     }
   }
+
+  @Test
+  public void testRecreatedPeerZnodeVersionZeroIsDeliveredOnce() throws 
Exception {
+    Configuration conf = new Configuration();
+    conf.setLong(HA_GROUP_STORE_PEER_CACHE_RETRY_INTERVAL_SECONDS, 0L);
+    AtomicInteger deliveries = new AtomicInteger();
+    PeerClusterWatcher watcher =
+      new PeerClusterWatcher(conf, "g", "ns", new 
PeerClusterWatcher.PeerStateListener() {
+        @Override
+        public void onPeerStateChanged(HAGroupStoreRecord peerRecord, Stat 
stat) {
+          deliveries.incrementAndGet();
+        }
+
+        @Override
+        public void onPeerVisible() {
+        }
+
+        @Override
+        public void onPeerBlind() {
+        }
+      });
+    try {
+      watcher.deliver(recordAndStat(10L, 3), false);
+      watcher.deliver(recordAndStat(20L, 0), false);
+      watcher.deliver(recordAndStat(20L, 0), false);
+      watcher.deliver(recordAndStat(20L, 1), false);
+      watcher.deliver(recordAndStat(5L, 0), false);
+      assertEquals("new znode incarnations and newer versions should be 
delivered exactly once", 4,
+        deliveries.get());
+    } finally {
+      watcher.close();
+    }
+  }
+
+  private static Pair<HAGroupStoreRecord, Stat> recordAndStat(long czxid, int 
version) {
+    HAGroupStoreRecord record = new HAGroupStoreRecord("v1.0", "g",
+      HAGroupStoreRecord.HAGroupState.STANDBY, 0L, "FAILOVER", null, null, 
null, null, null, 1L);
+    Stat stat = new Stat();
+    stat.setCzxid(czxid);
+    stat.setVersion(version);
+    return Pair.of(record, stat);
+  }
 }
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolTest.java
new file mode 100644
index 0000000000..d223a64254
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/PhoenixHAAdminToolTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.phoenix.jdbc;
+
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ABORT_TO_ACTIVE_IN_SYNC;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ABORT_TO_STANDBY;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC_TO_STANDBY;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ACTIVE_NOT_IN_SYNC;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ACTIVE_NOT_IN_SYNC_WITH_OFFLINE_PEER;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.ACTIVE_WITH_OFFLINE_PEER;
+import static 
org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.DEGRADED_STANDBY;
+import static org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.OFFLINE;
+import static org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.STANDBY;
+import static org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState.UNKNOWN;
+import static org.apache.phoenix.jdbc.PhoenixHAAdminTool.RET_ARGUMENT_ERROR;
+import static org.apache.phoenix.jdbc.PhoenixHAAdminTool.RET_SUCCESS;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import org.junit.Test;
+
+public class PhoenixHAAdminToolTest {
+
+  @Test
+  public void testStableFailoverPairRejectsTransitionalRoles() {
+    assertTrue(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
ACTIVE_IN_SYNC));
+    assertTrue(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
ACTIVE_NOT_IN_SYNC));
+
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(ABORT_TO_STANDBY, 
ACTIVE_IN_SYNC));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
ABORT_TO_ACTIVE_IN_SYNC));
+    
assertFalse(PhoenixHAAdminTool.isStableFailoverPair(ACTIVE_IN_SYNC_TO_STANDBY, 
STANDBY));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(DEGRADED_STANDBY, 
ACTIVE_IN_SYNC));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
DEGRADED_STANDBY));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, OFFLINE));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, UNKNOWN));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
ACTIVE_WITH_OFFLINE_PEER));
+    assertFalse(
+      PhoenixHAAdminTool.isStableFailoverPair(STANDBY, 
ACTIVE_NOT_IN_SYNC_WITH_OFFLINE_PEER));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(null, ACTIVE_IN_SYNC));
+    assertFalse(PhoenixHAAdminTool.isStableFailoverPair(STANDBY, null));
+  }
+
+  @Test
+  public void testTopLevelHelpReturnsSuccess() throws Exception {
+    PhoenixHAAdminTool tool = new PhoenixHAAdminTool();
+    assertEquals(RET_SUCCESS, tool.run(new String[] { "help" }));
+    assertEquals(RET_SUCCESS, tool.run(new String[] { "-h" }));
+    assertEquals(RET_SUCCESS, tool.run(new String[] { "--help" }));
+    assertEquals(RET_ARGUMENT_ERROR, tool.run(new String[0]));
+    assertEquals(RET_ARGUMENT_ERROR, tool.run(new String[] { "unknown" }));
+  }
+
+  @Test
+  public void testVersionOnlyUpdateExplainsThatAConfigurationFieldIsRequired() 
throws Exception {
+    PrintStream originalErr = System.err;
+    ByteArrayOutputStream error = new ByteArrayOutputStream();
+    try {
+      System.setErr(new PrintStream(error));
+      PhoenixHAAdminTool tool = new PhoenixHAAdminTool();
+      assertEquals(RET_ARGUMENT_ERROR, tool.run(new String[] { "update", "-g", 
"g", "-v", "1" }));
+    } finally {
+      System.setErr(originalErr);
+    }
+    assertTrue(error.toString().contains("in addition to"));
+  }
+}

Reply via email to