This is an automated email from the ASF dual-hosted git repository.
lokiore 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 9628412934 PHOENIX-7973 HA client can adopt a stale (lower-version)
ClusterRoleRecord when one endpoint lags the peer (#2589)
9628412934 is described below
commit 9628412934b66a25c91f70d9201b8d7db642936c
Author: Lokesh Khurana <[email protected]>
AuthorDate: Wed Aug 19 09:47:25 2026 -0700
PHOENIX-7973 HA client can adopt a stale (lower-version) ClusterRoleRecord
when one endpoint lags the peer (#2589)
* PHOENIX-7973 HA client can adopt a stale (lower-version)
ClusterRoleRecord when one endpoint lags the peer
getClusterRoleRecordFromEndpoint() queried cluster 1 first and returned it
immediately whenever it had no UNKNOWN role, without consulting cluster 2.
CRR version propagation across RegionServers is not synchronized, so at
startup or during an in-flight admin/failover transition one endpoint can
momentarily serve a lower admin version (or an UNKNOWN role) than its peer.
In that window the client adopted the staler, lower-version record and
silently reverted to an older cluster-role view. The refresh path guards
only with ClusterRoleRecord.equals() (which ignores version) and never
called the existing isNewerThan() helper, so nothing detected the downgrade.
Fix: always fetch the CRR from both cluster endpoints and reconcile via a
new package-private static reconcileClusterRoleRecords(): prefer a record
without an UNKNOWN role (a known-role record is usable for routing; an
UNKNOWN one is not), and within the same category prefer the higher admin
version. This is a strict superset of the previous UNKNOWN-only handling and
guarantees the client never adopts a CRR older than one a peer already
advertises. If the peer endpoint is unreachable, cluster 1's record is used
as-is. Adds one endpoint RPC to the CRR refresh path only; CRR is fetched on
connect/refresh (cached), not per query, so no meaningful perf impact.
Client-side only; no API or wire-format change.
Unit-tested via HighAvailabilityGroupTest#testReconcileClusterRoleRecords
(higher-version-wins regression guard, order-independence, non-UNKNOWN beats
UNKNOWN in both orders, UNKNOWN-vs-UNKNOWN higher-version).
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../apache/phoenix/jdbc/HighAvailabilityGroup.java | 266 ++++++++++---
.../phoenix/util/GetClusterRoleRecordUtil.java | 72 ++--
.../org/apache/phoenix/jdbc/HAGroupMetricsIT.java | 5 +-
.../phoenix/jdbc/HighAvailabilityGroupTest.java | 433 +++++++++++++++++++++
4 files changed, 694 insertions(+), 82 deletions(-)
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityGroup.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityGroup.java
index 017f996a31..05ce278764 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityGroup.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityGroup.java
@@ -24,6 +24,7 @@ import static
org.apache.phoenix.query.QueryServicesOptions.DEFAULT_CLIENT_CONNE
import static org.apache.phoenix.util.PhoenixRuntime.JDBC_PROTOCOL_SEPARATOR;
import java.io.IOException;
+import java.io.InterruptedIOException;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
@@ -772,6 +773,11 @@ public class HighAvailabilityGroup {
return roleRecord;
}
+ @VisibleForTesting
+ State getStateForTesting() {
+ return state;
+ }
+
/**
* Package private close method.
* <p>
@@ -979,65 +985,140 @@ public class HighAvailabilityGroup {
}
/**
- * Method to get ClusterRoleRecord from RegionServer Endpoints from either
of the clusters.
- * @return ClusterRoleRecord from the first available cluster
+ * Reads the CRR from both cluster endpoints and returns the more
authoritative one (see
+ * {@link #reconcileClusterRoleRecords}). Consulting both avoids adopting a
staler view when one
+ * endpoint momentarily lags its peer. If the peer (cluster 2) is
unreachable, cluster 1's record
+ * is used as-is; if cluster 1 fails, cluster 2's record is used as-is.
+ * <p>
+ * Endpoints are read via {@link
GetClusterRoleRecordUtil#getClusterRoleRecord} (a pure read); the
+ * non-active poller is scheduled at most once here, after reconciliation,
via
+ * {@link GetClusterRoleRecordUtil#maybeSchedulePoller} on the resolved
record — never off a
+ * single raw fetch (which churned a poller and widened a deadlock; see that
method).
+ * @return the reconciled ClusterRoleRecord, or — on a fallback path where
only one endpoint was
+ * reachable — that single endpoint's un-reconciled record
* @throws SQLException if there is an error getting the ClusterRoleRecord
*/
- private ClusterRoleRecord getClusterRoleRecordFromEndpoint() throws
SQLException {
+ @VisibleForTesting
+ ClusterRoleRecord getClusterRoleRecordFromEndpoint() throws SQLException {
long pollerInterval =
Long.parseLong(properties.getProperty(PHOENIX_HA_CRR_POLLER_INTERVAL_MS_KEY,
config
.get(PHOENIX_HA_CRR_POLLER_INTERVAL_MS_KEY,
PHOENIX_HA_CRR_POLLER_INTERVAL_MS_DEFAULT)));
+ ClusterRoleRecord resolvedRecord = null;
+ ClusterRoleRecord roleRecord1 = null;
try {
- // Get the CRR via RSEndpoint for cluster 1
- ClusterRoleRecord roleRecord =
GetClusterRoleRecordUtil.fetchClusterRoleRecord(info.getUrl1(),
- info.getUrl2(), info.getUrl1(), info.getName(), this, pollerInterval,
properties);
- // If we have unknown role for any cluster then try getting CRR from
cluster 2 endpoint and if
- // we get unknown role from there as well then CRR with higher
adminVersion wins.
- if (roleRecord.hasUnknownRole()) {
- ClusterRoleRecord roleRecordFromPR;
- try {
- roleRecordFromPR =
GetClusterRoleRecordUtil.fetchClusterRoleRecord(info.getUrl1(),
- info.getUrl2(), info.getUrl2(), info.getName(), this,
pollerInterval, properties);
- } catch (Exception e) {
- // As we were able to get CRR from cluster 1 but cluster 2 threw
exception then just
- // return
- // CRR from cluster 1 and consume this exception
- LOG.warn("Role Record from cluster {} has Unknown Role but cluster
{} threw exception, "
- + "returning {} as CRR", info.getUrl1(), info.getUrl2(),
roleRecord.toPrettyString());
- return roleRecord;
- }
- if (roleRecordFromPR.hasUnknownRole()) {
- return roleRecord.getVersion() > roleRecordFromPR.getVersion()
- ? roleRecord
- : roleRecordFromPR;
+ // Read cluster 1's CRR (read-only; no poller side effect).
+ roleRecord1 = fetchClusterRoleRecord(info.getUrl1());
+ } catch (Exception e) {
+ // Cluster 1 failed: fall back to cluster 2 (single endpoint, no
reconciliation). Log first,
+ // otherwise a cluster-1 failure (including an unchecked bug such as an
NPE) vanishes whenever
+ // cluster 2 succeeds.
+ LOG.warn("Cluster 1 endpoint {} for HA group {} threw an exception;
attempting cluster 2 "
+ + "endpoint {}", info.getUrl1(), info.getName(), info.getUrl2(), e);
+ // Restore the interrupt status the cluster-1 fetch cleared in a
finally, AFTER the fallback
+ // fetch on every exit path (normal or thrown): a stale flag set before
the fetch would
+ // pre-empt the blocking work we depend on, but callers must still
observe cancellation.
+ boolean cluster1Interrupted = isCausedByInterrupt(e);
+ try {
+ // On CRR-Not-Found, if cluster 2 also fails rethrow the original
Not-Found (with the
+ // cluster-2 failure suppressed) so downstream fallback can trigger.
+ if (
+ e instanceof SQLException && ((SQLException) e).getErrorCode()
+ == SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode()
+ ) {
+ try {
+ resolvedRecord = fetchClusterRoleRecord(info.getUrl2());
+ } catch (Exception ignoredEx) {
+ ((SQLException) e).addSuppressed(ignoredEx);
+ throw (SQLException) e;
+ }
} else {
- return roleRecordFromPR;
+ // If caught exception is not CRR-Not-Found, try the cluster 2
endpoint. If cluster 2 also
+ // fails, attach cluster 1's failure as suppressed so the single
propagating exception
+ // carries both root causes (parity with the Not-Found path above).
+ try {
+ resolvedRecord = fetchClusterRoleRecord(info.getUrl2());
+ } catch (Exception cluster2Ex) {
+ cluster2Ex.addSuppressed(e);
+ throw cluster2Ex;
+ }
+ }
+ } finally {
+ if (cluster1Interrupted) {
+ Thread.currentThread().interrupt();
}
- } else {
- return roleRecord;
}
- } catch (Exception e) {
- // If we get CRR Not Found on cluster 1, we should still try cluster 2,
maybe
- // haGroupStoreClient
- // was not initialized somehow, but if we get any exception from cluster
2 too then we should
- // throw CRR not found so that fallback can happen.
- if (
- e instanceof SQLException && ((SQLException) e).getErrorCode()
- == SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode()
- ) {
- try {
- return
GetClusterRoleRecordUtil.fetchClusterRoleRecord(info.getUrl1(), info.getUrl2(),
- info.getUrl2(), info.getName(), this, pollerInterval, properties);
- } catch (Exception ignoredEx) {
- throw (SQLException) e;
+ }
+
+ if (roleRecord1 != null) {
+ // Cluster 1 reachable. Read cluster 2's CRR; if cluster 2 is
unreachable, degrade to
+ // cluster 1's record.
+ ClusterRoleRecord roleRecord2 = null;
+ try {
+ roleRecord2 = fetchClusterRoleRecord(info.getUrl2());
+ } catch (Exception e) {
+ // Any cluster 2 fetch failure degrades to cluster 1's record. Catch
broadly: an unchecked
+ // exception from the pre-RPC connect path is still a reachability
failure. No further
+ // blocking work follows here, so restore the interrupt status
immediately if the failure
+ // wrapped one, so callers can observe cancellation.
+ if (isCausedByInterrupt(e)) {
+ Thread.currentThread().interrupt();
+ }
+ LOG.warn(
+ "Fetched CRR {} from cluster {} but cluster {} endpoint threw an
exception; "
+ + "returning cluster {} CRR without peer reconciliation",
+ roleRecord1.toPrettyString(), info.getUrl1(), info.getUrl2(),
info.getUrl1(), e);
+ }
+ if (roleRecord2 == null) {
+ resolvedRecord = roleRecord1;
+ } else {
+ // Both endpoints reachable. Reconcile OUTSIDE both fetch try/catch
blocks: it is pure
+ // computation, so a bug here surfaces as itself rather than being
caught by a fetch catch,
+ // misreported as an endpoint failure, and silently degraded to a
single-endpoint record.
+ // Pass the currently applied record (this.roleRecord; null on first
load) so an
+ // equal-version divergence defers to it rather than flapping.
+ resolvedRecord = reconcileClusterRoleRecords(roleRecord1, roleRecord2,
this.roleRecord);
+ if (!roleRecord1.equals(roleRecord2)) {
+ LOG.info(
+ "Reconciled divergent CRRs for HA group {}: cluster1={} (V{}),
cluster2={} (V{}); "
+ + "chose {} (V{})",
+ info.getName(), roleRecord1, roleRecord1.getVersion(), roleRecord2,
+ roleRecord2.getVersion(), resolvedRecord,
resolvedRecord.getVersion());
}
}
+ }
+
+ // resolvedRecord is non-null here: either the cluster-1 fallback set it,
or the cluster-1
+ // reachable branch did. Schedule the non-active CRR poller at most once,
gated on the resolved
+ // record. maybeSchedulePoller is a no-op when the record has an active
role, so this is safe to
+ // call unconditionally.
+ GetClusterRoleRecordUtil.maybeSchedulePoller(info.getUrl1(),
info.getUrl2(), info.getName(),
+ this, resolvedRecord, pollerInterval, properties);
+ return resolvedRecord;
+ }
- // If caught exception is not CRR not found, then just try cluster 2
endpoint.
- return GetClusterRoleRecordUtil.fetchClusterRoleRecord(info.getUrl1(),
info.getUrl2(),
- info.getUrl2(), info.getName(), this, pollerInterval, properties);
+ /**
+ * Reads a single endpoint's CRR (a pure read; no poller side effect).
Extracted as a seam so unit
+ * tests can stub per-endpoint fetches without a mini-cluster.
+ */
+ @VisibleForTesting
+ ClusterRoleRecord fetchClusterRoleRecord(String url) throws SQLException {
+ return GetClusterRoleRecordUtil.getClusterRoleRecord(url, info.getName(),
true, properties);
+ }
+
+ /**
+ * True if {@code t}'s cause chain (bounded against cyclic causes) carries
an interruption marker.
+ * A blocking endpoint RPC surfaces the interruption wrapped inside the
thrown exception, and the
+ * JVM has already cleared the thread's interrupt flag, so the caller must
restore it explicitly.
+ */
+ @VisibleForTesting
+ static boolean isCausedByInterrupt(Throwable t) {
+ for (int depth = 0; t != null && depth < 16; t = t.getCause(), depth++) {
+ if (t instanceof InterruptedException || t instanceof
InterruptedIOException) {
+ return true;
+ }
}
+ return false;
}
/**
@@ -1106,6 +1187,16 @@ public class HighAvailabilityGroup {
return true;
}
+ // A lagging endpoint can serve an older admin version; do not roll back
to it.
+ if (!shouldApplyRefreshedRecord(roleRecord, newRoleRecord)) {
+ LOG.warn(
+ "Fetched role record {} is older (V{}) than the current record (V{})
for HA group {};"
+ + " keeping the current record and not rolling back",
+ newRoleRecord, newRoleRecord.getVersion(), roleRecord.getVersion(),
info);
+ lastClusterRoleRecordRefreshTime = System.currentTimeMillis();
+ return true;
+ }
+
final ClusterRoleRecord oldRecord = roleRecord;
state = State.IN_TRANSITION;
LOG.info("HA group {} is in {} to set V{} record", info, state,
newRoleRecord.getVersion());
@@ -1240,4 +1331,89 @@ public class HighAvailabilityGroup {
return transitionSucceeded &&
!oldRecord.getActiveUrl().equals(newRecord.getActiveUrl())
&& newRecord.getActiveUrl().isPresent();
}
+
+ /**
+ * Returns the more authoritative of the two records fetched from the
cluster 1 and cluster 2
+ * endpoints. Preference: a non-UNKNOWN record beats an UNKNOWN one (an
UNKNOWN role can't resolve
+ * cluster roles and isn't usable for routing, so it does not win on version
alone) UNLESS the
+ * UNKNOWN-tagged record is strictly newer and still names an active cluster
(a real state advance
+ * where the peer role is momentarily UNKNOWN mid-transition) — masking that
behind a stale
+ * fully-known record would keep routing to a since-demoted cluster.
Otherwise the higher admin
+ * {@code version} wins. On an equal-version divergence (both records at the
same version but with
+ * different roles, one endpoint lagging its peer) there is no freshness
signal to order them, so
+ * if {@code current} is already applied at that same version the currently
applied record is
+ * kept, deferring any transition until the endpoints converge; this avoids
flapping to a stale
+ * peer view and back. Once both endpoints agree on the new roles the
same-version record is
+ * returned and the autonomous transition applies as normal. A genuine
version advance is never
+ * dropped: the defer only fires when {@code current} sits at the endpoints'
version.
+ * Package-private for direct unit testing. The two fetched records may not
be {@code null};
+ * {@code current} is {@code null} on first load.
+ * @param recordFromCluster1 CRR fetched from the cluster 1 endpoint ({@code
info.getUrl1()})
+ * @param recordFromCluster2 CRR fetched from the cluster 2 endpoint ({@code
info.getUrl2()})
+ * @param current the currently applied CRR, or {@code null} on
first load
+ * @return the more authoritative of the two records
+ */
+ static ClusterRoleRecord reconcileClusterRoleRecords(ClusterRoleRecord
recordFromCluster1,
+ ClusterRoleRecord recordFromCluster2, ClusterRoleRecord current) {
+ // Exactly one record carries an UNKNOWN role. Prefer the usable
(non-UNKNOWN) record so the
+ // client keeps a routable view -- EXCEPT when the UNKNOWN-tagged record
is strictly newer AND
+ // still names an active cluster (one role resolved ACTIVE while its peer
is mid-transition and
+ // momentarily UNKNOWN). There the newer record reflects a real state
advance, and masking it
+ // behind a stale fully-known record would keep routing to a since-demoted
cluster, so the newer
+ // record wins. A newer UNKNOWN record with NO active role stays masked
behind the usable
+ // record: it cannot route a connection, so recovery comes from the next
scheduled refresh (and
+ // the poller only if the usable record is itself non-active, which
schedules it after return).
+ if (recordFromCluster1.hasUnknownRole() !=
recordFromCluster2.hasUnknownRole()) {
+ ClusterRoleRecord unknownRecord =
+ recordFromCluster1.hasUnknownRole() ? recordFromCluster1 :
recordFromCluster2;
+ ClusterRoleRecord usableRecord =
+ recordFromCluster1.hasUnknownRole() ? recordFromCluster2 :
recordFromCluster1;
+ if (
+ unknownRecord.getVersion() > usableRecord.getVersion()
+ && unknownRecord.getActiveUrl().isPresent()
+ ) {
+ return unknownRecord;
+ }
+ return usableRecord;
+ }
+ // Different versions: keep the higher admin version.
+ if (recordFromCluster1.getVersion() != recordFromCluster2.getVersion()) {
+ return recordFromCluster1.getVersion() > recordFromCluster2.getVersion()
+ ? recordFromCluster1
+ : recordFromCluster2;
+ }
+ // Equal version: if both endpoints agree, return it (a legitimate
same-version autonomous
+ // transition applies once both endpoints reflect the new roles).
+ if (recordFromCluster1.equals(recordFromCluster2)) {
+ return recordFromCluster2;
+ }
+ // Equal-version divergence with no freshness signal. If the currently
applied record sits at
+ // this same version, keep it and defer the transition until the endpoints
converge, rather
+ // than arbitrarily adopting the peer and flapping. Scoping the defer to
current's version
+ // ensures a genuine version advance is never suppressed.
+ if (
+ current != null && current.hasSameInfo(recordFromCluster1)
+ && current.getVersion() == recordFromCluster1.getVersion()
+ ) {
+ return current;
+ }
+ // First load, or current at a lower version than the (equal) endpoint
version: fall back to
+ // the deterministic peer record so a real advance is not dropped.
+ return recordFromCluster2;
+ }
+
+ /**
+ * Whether a freshly fetched record should replace the applied one on the
refresh path. Rejects
+ * only a strictly lower version (a rollback to a stale view from a lagging
endpoint). An
+ * <em>equal</em> version is still applied by design: an autonomous
transition changes roles while
+ * keeping the same admin version, so a same-version record with changed
roles is a legitimate
+ * update. Equivalent to {@code !current.isNewerThan(fetched)}.
Package-private for direct unit
+ * testing.
+ * @param current the currently applied {@link ClusterRoleRecord} (must be
non-null)
+ * @param fetched the candidate record freshly fetched from the endpoints
+ * @return {@code true} to apply {@code fetched}; {@code false} to keep
{@code current}
+ */
+ static boolean shouldApplyRefreshedRecord(ClusterRoleRecord current,
ClusterRoleRecord fetched) {
+ return !current.isNewerThan(fetched);
+ }
}
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/util/GetClusterRoleRecordUtil.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/util/GetClusterRoleRecordUtil.java
index 16c7715c17..b25d5f46f2 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/util/GetClusterRoleRecordUtil.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/util/GetClusterRoleRecordUtil.java
@@ -75,16 +75,19 @@ public class GetClusterRoleRecordUtil {
}
/**
- * Method to get ClusterRoleRecord from RegionServer Endpoints. it picks a
random region server
- * and gets the CRR from it.
+ * Reads the ClusterRoleRecord from a random live region server at {@code
url}. Pure read, no side
+ * effects: callers needing the non-active poller must reconcile both
endpoints first and then
+ * call {@link #maybeSchedulePoller} on the result. Scheduling off a single
raw fetch churned a
+ * poller for an ACTIVE-plus-stale-peer state and widened a {@code
pollerLock}/write-lock AB-BA
+ * inversion into a reachable deadlock.
* @param url URL to create Connection to be used to get
RegionServer Endpoint Service
* @param haGroupName Name of the HA group
* @param doRetry Whether to retry if the operation fails
* @param properties Connection properties
- * @return ClusterRoleRecord from the first available cluster
+ * @return ClusterRoleRecord read from the endpoint at {@code url}
* @throws SQLException if there is an error getting the ClusterRoleRecord
*/
- private static ClusterRoleRecord getClusterRoleRecord(String url, String
haGroupName,
+ public static ClusterRoleRecord getClusterRoleRecord(String url, String
haGroupName,
boolean doRetry, Properties properties) throws SQLException {
Connection conn = getConnection(url, properties);
PhoenixConnection connection = conn.unwrap(PhoenixConnection.class);
@@ -149,44 +152,35 @@ public class GetClusterRoleRecordUtil {
}
/**
- * Method to schedule a poller to fetch ClusterRoleRecord every 5 seconds
(or configured value)
- * until we get an Active ClusterRoleRecord (one role should be Active) if
we receive an Active
- * roleRecord then client this method will return the roleRecord to be
consumed and used, if not
- * then it will start a poller and return non-active roleRecord.
- * <p>
- * The poller alternates between {@code url1} and {@code url2} on successive
ticks so a transient
- * outage on one cluster does not stall progress; both URLs are passed in
even though the initial
- * fetch only targets one of them (selected by the caller via the {@code
primaryUrl} hint).
- * @param url1 URL of the RegionServer Endpoint Service for
cluster 1
- * @param url2 URL of the RegionServer Endpoint Service for
cluster 2
- * @param primaryUrl URL to use for the initial (non-poller) fetch; must
be either url1 or
- * url2
- * @param haGroupName Name of the HA group
- * @param haGroup HighAvailabilityGroup object to refresh the
ClusterRoleRecord when an
- * Active CRR is found
- * @param pollerInterval Interval in milliseconds to poll for
ClusterRoleRecord
- * @param properties Connection properties
- * @throws SQLException if there is an error getting the ClusterRoleRecord
+ * Starts the non-active CRR poller for {@code haGroupName} only when {@code
reconciledRecord} is
+ * a FAILOVER record with no active role (neither cluster can take a
connection); otherwise a
+ * no-op, so callers may invoke it unconditionally on the reconciled record.
+ * {@code reconciledRecord} must be the reconciled view of both endpoints —
see
+ * {@link #getClusterRoleRecord} for why scheduling off a single raw fetch
is unsafe. The poller
+ * alternates {@code url1}/{@code url2} each tick (so a transient outage on
one cluster does not
+ * stall progress) until an ACTIVE CRR appears.
+ * @param url1 URL of the RegionServer Endpoint Service for
cluster 1
+ * @param url2 URL of the RegionServer Endpoint Service for
cluster 2
+ * @param haGroupName Name of the HA group
+ * @param haGroup HighAvailabilityGroup object to refresh the
ClusterRoleRecord when an
+ * Active CRR is found
+ * @param reconciledRecord the reconciled CRR (across both endpoints) to
gate scheduling on
+ * @param pollerInterval Interval in milliseconds to poll for
ClusterRoleRecord
+ * @param properties Connection properties
*/
- public static ClusterRoleRecord fetchClusterRoleRecord(String url1, String
url2,
- String primaryUrl, String haGroupName, HighAvailabilityGroup haGroup, long
pollerInterval,
- Properties properties) throws SQLException {
- ClusterRoleRecord clusterRoleRecord =
- getClusterRoleRecord(primaryUrl, haGroupName, true, properties);
+ public static void maybeSchedulePoller(String url1, String url2, String
haGroupName,
+ HighAvailabilityGroup haGroup, ClusterRoleRecord reconciledRecord, long
pollerInterval,
+ Properties properties) {
if (
- clusterRoleRecord.getPolicy() == HighAvailabilityPolicy.FAILOVER
- && !clusterRoleRecord.getRole1().isActive() &&
!clusterRoleRecord.getRole2().isActive()
+ reconciledRecord.getPolicy() == HighAvailabilityPolicy.FAILOVER
+ && !reconciledRecord.getRole1().isActive() &&
!reconciledRecord.getRole2().isActive()
) {
LOGGER.info(
"Non-active ClusterRoleRecord found for HA group {}. Scheduling poller
to check every {} ms,"
+ " alternating between url1 and url2 until we find an ACTIVE CRR",
haGroupName, pollerInterval);
- // Schedule a poller to fetch ClusterRoleRecord every pollerInterval
milliseconds
- // until we get an Active ClusterRoleRecord and return the Non-Active CRR
schedulePoller(url1, url2, haGroupName, haGroup, pollerInterval,
properties);
}
-
- return clusterRoleRecord;
}
/**
@@ -251,16 +245,24 @@ public class GetClusterRoleRecordUtil {
LOGGER.info("Active ClusterRoleRecord found for HA group {}.
Cancelling poller.",
haGroupName);
+ // Elect a single winner tick under pollerLock, but refresh
OUTSIDE it: refresh grabs
+ // the HA-group write lock, which the connect path holds before
reaching pollerLock, so
+ // refreshing under pollerLock would close an AB-BA deadlock. Only
the tick that removed
+ // the future refreshes and shuts the scheduler down, so each
happens exactly once.
+ boolean winner;
+ ScheduledExecutorService scheduler;
synchronized (pollerLock) {
ScheduledFuture<?> future = futureMap.remove(haGroupName);
if (future != null) {
future.cancel(false);
}
+ winner = future != null;
+ scheduler = schedulerMap.remove(haGroupName);
+ }
+ if (winner) {
try {
- // Refresh ClusterRoleRecord for the HAGroup with appropriate
transition
haGroup.refreshClusterRoleRecord(true);
} finally {
- ScheduledExecutorService scheduler =
schedulerMap.remove(haGroupName);
if (scheduler != null) {
scheduler.shutdown();
}
diff --git
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupMetricsIT.java
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupMetricsIT.java
index 9589204cb5..7f02288cc6 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupMetricsIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupMetricsIT.java
@@ -248,8 +248,9 @@ public class HAGroupMetricsIT extends HABaseIT {
@Test(timeout = 300000)
public void testPollerTickCount() throws Exception {
- // The poller starts only when fetchClusterRoleRecord observes both roles
non-active under
- // FAILOVER policy. Drive that state, await a couple of ticks, and verify
the counter moved.
+ // The poller starts only when the reconciled CRR has both roles
non-active under FAILOVER
+ // policy (maybeSchedulePoller). Drive that state, await a couple of
ticks, and verify the
+ // counter moved.
long beforeTicks = GLOBAL_HA_POLLER_TICK_COUNT.getMetric().getValue();
long beforeFailures =
GLOBAL_HA_POLLER_TICK_FAILURES.getMetric().getValue();
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityGroupTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityGroupTest.java
index cc58727838..5bf956fa88 100644
---
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityGroupTest.java
+++
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityGroupTest.java
@@ -20,13 +20,21 @@ package org.apache.phoenix.jdbc;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import java.io.IOException;
+import java.io.InterruptedIOException;
import java.sql.SQLException;
import java.util.Properties;
import org.apache.phoenix.exception.SQLExceptionCode;
import org.apache.phoenix.jdbc.ClusterRoleRecord.ClusterRole;
+import org.apache.phoenix.jdbc.HighAvailabilityGroup.HAGroupInfo;
+import org.apache.phoenix.jdbc.HighAvailabilityGroup.State;
+import org.apache.phoenix.monitoring.GlobalClientMetrics;
import org.junit.Test;
+import org.mockito.Mockito;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -185,4 +193,429 @@ public class HighAvailabilityGroupTest {
+ "should count as a failover",
HighAvailabilityGroup.shouldCountFailover(true, bothStandby,
aStandbyBActive));
}
+
+ /** Reconciliation prefers non-UNKNOWN over UNKNOWN, then higher version;
order-independent. */
+ @Test
+ public void testReconcileClusterRoleRecords() {
+ String haGroupName = "testReconcileClusterRoleRecords";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+
+ ClusterRoleRecord v9 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER, url1,
+ ClusterRole.ACTIVE, url2, ClusterRole.STANDBY, 9L);
+ ClusterRoleRecord v10 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 10L);
+ ClusterRoleRecord unknownV11 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.UNKNOWN, url2,
ClusterRole.STANDBY, 11L);
+ ClusterRoleRecord unknownV12 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.UNKNOWN, url2,
ClusterRole.UNKNOWN, 12L);
+
+ // Both usable: higher version wins (the stale-revert regression guard),
either order.
+ assertSame("Higher version must win when cluster 1 lags the peer", v10,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v9, v10, null));
+ assertSame("Higher version must win regardless of argument order", v10,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v10, v9, null));
+
+ // Non-UNKNOWN beats UNKNOWN even at a lower version, either order.
+ assertSame("Non-UNKNOWN must beat UNKNOWN even with a lower version
(cluster 1 usable)", v9,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v9, unknownV11, null));
+ assertSame("Non-UNKNOWN must beat UNKNOWN even with a lower version
(cluster 2 usable)", v9,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(unknownV11, v9, null));
+
+ // UNKNOWN vs UNKNOWN: higher version wins.
+ assertSame("Among two UNKNOWN records the higher version wins", unknownV12,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(unknownV11,
unknownV12, null));
+
+ // A strictly newer UNKNOWN-tagged record that STILL names an active
cluster (one role ACTIVE,
+ // peer momentarily UNKNOWN mid-transition) must beat a stale fully-known
record: masking it
+ // would keep routing to the since-demoted cluster. Either order.
+ ClusterRoleRecord activeUnknownV13 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.UNKNOWN, 13L);
+ assertSame(
+ "Newer UNKNOWN-tagged record that still names an ACTIVE cluster must win
(cluster 2)",
+ activeUnknownV13,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v9, activeUnknownV13,
null));
+ assertSame(
+ "Newer UNKNOWN-tagged record that still names an ACTIVE cluster must win
(cluster 1)",
+ activeUnknownV13,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(activeUnknownV13, v9,
null));
+
+ // But a newer UNKNOWN record with NO active role stays masked behind the
usable record: it
+ // cannot route a connection, so recovery comes from the next scheduled
refresh (the poller runs
+ // only if the usable record is itself non-active). unknownV11 (url1
UNKNOWN, url2 STANDBY, no
+ // active) is newer than v9 but not routable.
+ assertSame("Newer UNKNOWN record with no active role stays masked behind
the usable record", v9,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v9, unknownV11, null));
+
+ // Equal-version boundary: an active-tagged UNKNOWN record at the SAME
version as a fully-known
+ // usable record must NOT win. The newer-UNKNOWN carve-out is strict '>',
so only a strictly
+ // newer active-unknown record displaces the usable one; a same-version
peer cannot. This pins
+ // the strict boundary — a '>=' mutant would wrongly route to the
active-unknown record. Either
+ // order.
+ ClusterRoleRecord activeUnknownV9 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.UNKNOWN, 9L);
+ assertSame("Equal-version active-unknown must NOT displace the fully-known
usable record", v9,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(v9, activeUnknownV9,
null));
+ assertSame("Equal-version active-unknown must NOT displace the usable
record (order swapped)",
+ v9, HighAvailabilityGroup.reconcileClusterRoleRecords(activeUnknownV9,
v9, null));
+ }
+
+ /**
+ * On an equal-version divergence (endpoints at the same version but
different roles, one lagging)
+ * reconcile keeps the currently applied record when it sits at that
version, deferring the
+ * transition until the endpoints converge; once both endpoints agree the
same-version record is
+ * returned. A genuine version advance is never dropped.
+ */
+ @Test
+ public void testReconcileEqualVersionDivergence() {
+ String haGroupName = "testReconcileEqualVersionDivergence";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+
+ // Applied record: url1 ACTIVE, url2 STANDBY at v10.
+ ClusterRoleRecord current = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.ACTIVE, url2, ClusterRole.STANDBY, 10L);
+ // Same version, diverging roles — a lagging/leading peer mid-propagation.
+ ClusterRoleRecord divergentV10 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.ACTIVE, 10L);
+ // Both endpoints agreeing on the new same-version roles (propagation
complete).
+ ClusterRoleRecord agreedV10a = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.STANDBY, 10L);
+ ClusterRoleRecord agreedV10b = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.STANDBY, 10L);
+ // A genuine version advance both endpoints see.
+ ClusterRoleRecord v11 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 11L);
+
+ // Equal-version divergence with current at that version → keep current
(no flap), either order.
+ assertSame("Equal-version divergence must keep the applied record (peer
diverges)", current,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(current, divergentV10,
current));
+ assertSame("Equal-version divergence defers regardless of which endpoint
diverges", current,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(divergentV10, current,
current));
+
+ // Both endpoints agree on the new same-version roles → apply it
(autonomous transition). The
+ // equal-values branch returns recordFromCluster2, and the returned record
differs from current
+ // so the refresh path will apply it.
+ assertSame("Both endpoints agreeing on a same-version role change must be
applied", agreedV10b,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(agreedV10a,
agreedV10b, current));
+ assertFalse("Agreed same-version record must differ from current so
refresh applies it",
+ current.equals(agreedV10b));
+
+ // First load (current == null) with an equal-version divergence →
deterministic peer record.
+ assertSame("First-load equal-version divergence falls back to the peer
record", divergentV10,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(current, divergentV10,
null));
+
+ // Equal-version divergence where the applied record is at a LOWER version
than the endpoints:
+ // the defer guard (current.version == endpoints' version) does not fire,
so this falls through
+ // to the deterministic peer record rather than keeping the stale applied
record. This is the
+ // other branch of the fall-through the first-load case cannot reach
(current != null).
+ ClusterRoleRecord olderCurrentV9 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.STANDBY, 9L);
+ assertSame(
+ "Equal-version divergence with the applied record at a lower version
falls through to the "
+ + "peer record (the defer only fires when current sits at the
endpoints' version)",
+ divergentV10,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(current, divergentV10,
olderCurrentV9));
+
+ // A genuine version advance is applied even though current is at the
older version.
+ assertSame("A strictly newer version must still win over the applied
record", v11,
+ HighAvailabilityGroup.reconcileClusterRoleRecords(current, v11,
current));
+ }
+
+ /** Refresh guard rejects a lower version but applies a same-version role
change. */
+ @Test
+ public void testShouldApplyRefreshedRecord() {
+ String haGroupName = "testShouldApplyRefreshedRecord";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+
+ ClusterRoleRecord v9 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER, url1,
+ ClusterRole.ACTIVE, url2, ClusterRole.STANDBY, 9L);
+ ClusterRoleRecord v10 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 10L);
+ // Same version as v10 but different roles — models an autonomous
transition (roles change,
+ // admin version does not).
+ ClusterRoleRecord v10RolesChanged = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.STANDBY, 10L);
+
+ assertFalse("Must not roll back from the applied v10 to a stale fetched
v9",
+ HighAvailabilityGroup.shouldApplyRefreshedRecord(v10, v9));
+ assertTrue("Must apply a strictly newer fetched record",
+ HighAvailabilityGroup.shouldApplyRefreshedRecord(v9, v10));
+ // A strict '>' guard would wrongly reject this same-version role change.
+ assertTrue("Must apply a same-version record with changed roles
(autonomous transition)",
+ HighAvailabilityGroup.shouldApplyRefreshedRecord(v10, v10RolesChanged));
+ }
+
+ /**
+ * Pins {@link HighAvailabilityGroup#isCausedByInterrupt}: both interrupt
marker types (direct and
+ * wrapped) are detected, non-interrupt chains are not, and the depth-16
bound stops the walk so a
+ * self-referential (cyclic) cause chain terminates rather than spinning
forever.
+ */
+ @Test
+ public void testIsCausedByInterrupt() {
+ assertTrue("Direct InterruptedException must be detected",
+ HighAvailabilityGroup.isCausedByInterrupt(new InterruptedException()));
+ assertTrue("Direct InterruptedIOException must be detected",
+ HighAvailabilityGroup.isCausedByInterrupt(new InterruptedIOException()));
+ assertTrue("A wrapped interrupt marker must be detected",
HighAvailabilityGroup
+ .isCausedByInterrupt(new SQLException("rpc", new
InterruptedIOException())));
+ assertFalse("A non-interrupt cause chain must not be detected",
+ HighAvailabilityGroup.isCausedByInterrupt(new SQLException("rpc", new
IOException("io"))));
+ assertFalse("null must not be detected",
HighAvailabilityGroup.isCausedByInterrupt(null));
+
+ // An interrupt marker at chain index 15 is within the depth-16 bound →
detected.
+ Throwable withinBound = new InterruptedException("deep");
+ for (int i = 0; i < 15; i++) {
+ withinBound = new RuntimeException("wrap" + i, withinBound);
+ }
+ assertTrue("An interrupt marker at depth 15 (within the bound) must be
detected",
+ HighAvailabilityGroup.isCausedByInterrupt(withinBound));
+
+ // An interrupt marker at chain index 16 is beyond the bound → not
detected.
+ Throwable beyondBound = new InterruptedException("deeper");
+ for (int i = 0; i < 16; i++) {
+ beyondBound = new RuntimeException("wrap" + i, beyondBound);
+ }
+ assertFalse("An interrupt marker at depth 16 (beyond the bound) must not
be detected",
+ HighAvailabilityGroup.isCausedByInterrupt(beyondBound));
+
+ // A self-referential cause chain must terminate (the depth bound is the
cycle guard).
+ Throwable selfCycle = new RuntimeException() {
+ @Override
+ public synchronized Throwable getCause() {
+ return this;
+ }
+ };
+ assertFalse("A cyclic cause chain must terminate and not be detected",
+ HighAvailabilityGroup.isCausedByInterrupt(selfCycle));
+ }
+
+ /**
+ * Wires the refresh no-rollback branch end to end: when the endpoint serves
a strictly older
+ * record than the applied one, {@code refreshClusterRoleRecord} keeps the
applied record, stays
+ * {@code READY}, does not count a failover, and returns {@code true}. A
passing
+ * {@code shouldApplyRefreshedRecord} unit test alone does not prove this
branch is wired — an
+ * inverted guard would silently reintroduce the rollback with the helper
test still green.
+ */
+ @Test
+ public void testRefreshDoesNotRollBackToOlderRecord() throws Exception {
+ String haGroupName = "testRefreshDoesNotRollBackToOlderRecord";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ ClusterRoleRecord appliedV10 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.STANDBY, 10L);
+ ClusterRoleRecord staleV9 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 9L);
+
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(),
appliedV10, State.READY));
+ Mockito.doReturn(staleV9).when(group).getClusterRoleRecordFromEndpoint();
+
+ long failoverBefore =
GlobalClientMetrics.GLOBAL_HA_FAILOVER_COUNT.getMetric().getValue();
+ assertTrue("Refresh that would roll back must be a no-op returning true",
+ group.refreshClusterRoleRecord(true));
+ assertSame("The applied record must be kept, not rolled back to the older
fetched record",
+ appliedV10, group.getRoleRecord());
+ assertSame("HA group must stay READY after a rejected rollback",
State.READY,
+ group.getStateForTesting());
+ assertEquals("A rejected rollback must not count as a failover",
failoverBefore,
+ GlobalClientMetrics.GLOBAL_HA_FAILOVER_COUNT.getMetric().getValue());
+ }
+
+ /**
+ * Wires {@link HighAvailabilityGroup#getClusterRoleRecordFromEndpoint}:
because reconcile is
+ * order-independent for most cases, a url1/url2 fetch swap or a wrong
{@code current} argument
+ * would pass every reconcile helper test yet change first-load behavior. On
a first-load
+ * equal-version divergence reconcile returns the cluster-2 record, so the
resolved record must be
+ * the url2 fetch (a swap would surface the url1 fetch); once a same-version
record is applied the
+ * defer must return that applied record (proving {@code this.roleRecord} is
threaded as
+ * {@code current}).
+ */
+ @Test
+ public void testGetClusterRoleRecordFromEndpointWiring() throws Exception {
+ String haGroupName = "testGetClusterRoleRecordFromEndpointWiring";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ ClusterRoleRecord fromUrl1 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.ACTIVE, url2, ClusterRole.STANDBY, 10L);
+ ClusterRoleRecord fromUrl2 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 10L);
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ // First load (current == null): equal-version divergence resolves to the
cluster-2 (url2)
+ // fetch.
+ HighAvailabilityGroup firstLoad =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ Mockito.doReturn(fromUrl1).when(firstLoad).fetchClusterRoleRecord(url1);
+ Mockito.doReturn(fromUrl2).when(firstLoad).fetchClusterRoleRecord(url2);
+ assertSame("url1 must be fetched as cluster 1 and url2 as cluster 2 (a
swap would return the "
+ + "url1 record)", fromUrl2,
firstLoad.getClusterRoleRecordFromEndpoint());
+
+ // Applied record at the endpoints' version: the equal-version defer must
return
+ // this.roleRecord,
+ // proving current is threaded (otherwise the fall-through would return
the url2 record).
+ ClusterRoleRecord appliedV10 = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.STANDBY, 10L);
+ HighAvailabilityGroup applied =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(),
appliedV10, State.READY));
+ Mockito.doReturn(fromUrl1).when(applied).fetchClusterRoleRecord(url1);
+ Mockito.doReturn(fromUrl2).when(applied).fetchClusterRoleRecord(url2);
+ assertSame("Equal-version divergence must defer to the applied record
(current threaded)",
+ appliedV10, applied.getClusterRoleRecordFromEndpoint());
+ }
+
+ /**
+ * Cluster-1 fails with CRR-Not-Found but cluster 2 serves a record: the
method falls back to the
+ * cluster-2 record and returns it (no reconciliation on a single reachable
endpoint).
+ */
+ @Test
+ public void testEndpointNotFoundOnCluster1FallsBackToCluster2() throws
Exception {
+ String haGroupName = "testEndpointNotFoundOnCluster1FallsBackToCluster2";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ ClusterRoleRecord fromUrl2 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 10L);
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ Mockito
+ .doThrow(
+ new SQLException("not found",
SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getSQLState(),
+ SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode()))
+ .when(group).fetchClusterRoleRecord(url1);
+ Mockito.doReturn(fromUrl2).when(group).fetchClusterRoleRecord(url2);
+
+ assertSame("A cluster-1 Not-Found must fall back to the cluster-2 record",
fromUrl2,
+ group.getClusterRoleRecordFromEndpoint());
+ }
+
+ /**
+ * Cluster-1 fails with CRR-Not-Found and cluster 2 also fails: the original
Not-Found propagates
+ * (so downstream single-cluster fallback can trigger) with the cluster-2
failure attached as a
+ * suppressed exception, so the single thrown exception carries both root
causes.
+ */
+ @Test
+ public void testEndpointBothFailNotFoundRethrowsNotFoundWithSuppressed()
throws Exception {
+ String haGroupName =
"testEndpointBothFailNotFoundRethrowsNotFoundWithSuppressed";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ SQLException notFound =
+ new SQLException("not found",
SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getSQLState(),
+ SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode());
+ SQLException cluster2Failure = new SQLException("cluster 2 unreachable");
+ Mockito.doThrow(notFound).when(group).fetchClusterRoleRecord(url1);
+ Mockito.doThrow(cluster2Failure).when(group).fetchClusterRoleRecord(url2);
+
+ try {
+ group.getClusterRoleRecordFromEndpoint();
+ fail("Expected the cluster-1 Not-Found to propagate when both endpoints
fail");
+ } catch (SQLException e) {
+ assertSame("The original Not-Found must propagate so downstream fallback
can trigger",
+ notFound, e);
+ assertEquals("Propagated Not-Found must keep its error code",
+ SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode(),
e.getErrorCode());
+ assertEquals("The cluster-2 failure must be attached as suppressed", 1,
+ e.getSuppressed().length);
+ assertSame(cluster2Failure, e.getSuppressed()[0]);
+ }
+ }
+
+ /**
+ * Cluster-1 fails with a non-Not-Found exception and cluster 2 also fails:
the cluster-2
+ * exception propagates (its distinct error, not the cluster-1 transport
failure) with the
+ * cluster-1 failure attached as suppressed, so the single thrown exception
carries both root
+ * causes. This is the else-branch parity with the Not-Found path — the
scenario where cluster 1
+ * is merely unreachable and cluster 2 then reports Not-Found.
+ */
+ @Test
+ public void testEndpointBothFailNonNotFoundRethrowsCluster2WithSuppressed()
throws Exception {
+ String haGroupName =
"testEndpointBothFailNonNotFoundRethrowsCluster2WithSuppressed";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ SQLException cluster1Failure = new SQLException("cluster 1 unreachable");
+ SQLException cluster2NotFound =
+ new SQLException("not found",
SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getSQLState(),
+ SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode());
+ Mockito.doThrow(cluster1Failure).when(group).fetchClusterRoleRecord(url1);
+ Mockito.doThrow(cluster2NotFound).when(group).fetchClusterRoleRecord(url2);
+
+ try {
+ group.getClusterRoleRecordFromEndpoint();
+ fail("Expected the cluster-2 exception to propagate when both endpoints
fail");
+ } catch (SQLException e) {
+ assertSame("The cluster-2 exception must propagate on the non-Not-Found
fallback path",
+ cluster2NotFound, e);
+ assertEquals("The cluster-1 failure must be attached as suppressed", 1,
+ e.getSuppressed().length);
+ assertSame(cluster1Failure, e.getSuppressed()[0]);
+ }
+ }
+
+ /**
+ * Cluster-1 is reachable but the cluster-2 fetch fails: the method degrades
to the cluster-1
+ * record (no reconciliation), rather than propagating the cluster-2 failure.
+ */
+ @Test
+ public void testEndpointCluster2FailureDegradesToCluster1() throws Exception
{
+ String haGroupName = "testEndpointCluster2FailureDegradesToCluster1";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ ClusterRoleRecord fromUrl1 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.ACTIVE, url2, ClusterRole.STANDBY, 10L);
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ Mockito.doReturn(fromUrl1).when(group).fetchClusterRoleRecord(url1);
+ Mockito.doThrow(new SQLException("cluster 2 unreachable")).when(group)
+ .fetchClusterRoleRecord(url2);
+
+ assertSame("A cluster-2 failure must degrade to the cluster-1 record",
fromUrl1,
+ group.getClusterRoleRecordFromEndpoint());
+ }
+
+ /**
+ * A cluster-1 fetch failure wrapping an interruption restores the thread's
interrupt status on
+ * the fallback path (the flag is cleared by the blocking fetch and must be
re-raised so callers
+ * still observe cancellation).
+ */
+ @Test
+ public void testEndpointRestoresInterruptStatusOnFallback() throws Exception
{
+ String haGroupName = "testEndpointRestoresInterruptStatusOnFallback";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+ ClusterRoleRecord fromUrl2 = new ClusterRoleRecord(haGroupName,
HighAvailabilityPolicy.FAILOVER,
+ url1, ClusterRole.STANDBY, url2, ClusterRole.ACTIVE, 10L);
+ HAGroupInfo info = new HAGroupInfo(haGroupName, url1, url2);
+
+ HighAvailabilityGroup group =
+ Mockito.spy(new HighAvailabilityGroup(info, new Properties(), null,
State.UNINITIALIZED));
+ // Non-Not-Found cluster-1 failure wrapping an interruption; cluster 2
then succeeds.
+ Mockito.doThrow(new SQLException("interrupted rpc", new
InterruptedIOException())).when(group)
+ .fetchClusterRoleRecord(url1);
+ Mockito.doReturn(fromUrl2).when(group).fetchClusterRoleRecord(url2);
+
+ // Clear any pre-existing interrupt status so the assertion is meaningful.
+ Thread.interrupted();
+ try {
+ assertSame("Fallback to cluster 2 must still return its record",
fromUrl2,
+ group.getClusterRoleRecordFromEndpoint());
+ assertTrue("The interrupt status must be restored after the fallback
fetch",
+ Thread.currentThread().isInterrupted());
+ } finally {
+ // Consume the interrupt flag so it does not leak into a reused fork.
+ Thread.interrupted();
+ }
+ }
}