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 b7175b0d26 PHOENIX-7872 :- HA observability metrics for poller, CRR
refresh/age, failover, mutation-block + RegionServer bypass counter (#2502)
b7175b0d26 is described below
commit b7175b0d26f4703c00a43b51f2a9f98081f726c5
Author: Lokesh Khurana <[email protected]>
AuthorDate: Mon Jul 13 11:26:39 2026 -0700
PHOENIX-7872 :- HA observability metrics for poller, CRR refresh/age,
failover, mutation-block + RegionServer bypass counter (#2502)
* PHOENIX-7872 :- HA observability metrics for poller, CRR refresh/age,
failover, mutation-block + RegionServer bypass counter
Adds client-side and server-side observability metrics for the
Consistent Failover (CCF) high-availability path:
Tier-1 client-side counters (4):
- HA_POLLER_TICK_COUNT — total poller ticks across all HA groups
- HA_POLLER_TICK_FAILURES — per-tick CRR fetch failures
- HA_FAILOVER_COUNT — failover transitions executed by the client
- HA_MUTATION_BLOCKED_COUNT — MutationBlockedIOException occurrences
detected via the wrap-and-propagate path
Tier-2 client-side metrics (4):
- HA_FAILOVER_DURATION_MS — failover end-to-end latency histogram
- HA_STALE_CRR_DETECTED_COUNT — StaleClusterRoleRecordException occurrences
- HA_CRR_CACHE_AGE_MS — gauge of staleness of the in-memory CRR cache
- (HA_FAILOVER_COUNT moved to applyClusterRoleRecord with role-transition
guard
so it only fires on actual ACTIVE -> STANDBY or STANDBY -> ACTIVE
transitions)
Tier-2 server-side counter (1):
- BYPASSED_MUTATION_BLOCK_COUNT — emitted from IndexRegionObserver when a
mutation bypasses the mutation-block check because no log group is
present.
Implemented as 3-file Hadoop-metrics2 source: interface +
static factory (DefaultMetricsSystem.instance()) + impl.
Tests:
- HAGroupMetricsIT — IT covering all 8 client-side metrics
- BypassedMutationBlockMetricsIT — IT covering server-side bypass counter
- HighAvailabilityUtilTest — UT covering
RetriesExhaustedWithDetailsException
+ IOException cause-chain MBIOE detection
- MetricsHaBypassSourceFactoryTest — UT covering factory thread-safety
Generated-by: Claude Code (Opus 4.7)
* PHOENIX-7872 :- Address apurtell review feedback (5 metric semantics + IT
catch narrowing + review nits)
Five must-fixes from the PR #2502 review:
1. HA_FAILOVER_COUNT semantic — moved increment inside the transition try
and
gated on transitionSucceeded so only successful policy transitions count;
preserves the existing metric name. Gate decision factored into the
package-private static HighAvailabilityGroup#shouldCountFailover for
direct unit-test coverage of the negative path (see Nit 1 below).
2. HA_CRR_REFRESH_COUNT semantic — moved increment to after a successful
getClusterRoleRecordFromEndpoint() so no-op refreshes inside the cache
window do NOT inflate the counter (counter now measures fresh fetches,
not refresh-method invocations).
3. HA_CRR_CACHE_AGE_MS sampling — added a poller-tick sample site so the
gauge updates on every poller iteration via a new HighAvailabilityGroup
#getCacheAgeMs() accessor. The connect()-site sample is retained.
4. BYPASSED_MUTATION_BLOCK_COUNT framing — rewrote the IndexRegionObserver
inline comment and MetricsHaBypassSource Javadoc/descriptions as a
path-coverage detector (counts the short-circuit code path; does NOT
imply any safety property was breached).
5. HAGroupMetricsIT catch narrowing — narrowed the stale-CRR catch from
the broad catch(Exception) to catch(SQLException) and asserted the
error code is FAILOVER_IN_PROGRESS (the contracted surface from
FailoverPhoenixConnection.wrapActionDuringFailover); added a LOG.info
so the expected exception is recorded.
Tests adjusted to match the new main-code semantics:
- HAGroupMetricsIT.testCrrRefreshCount — switched both refresh calls to
force-refresh so the assertion exercises the actual-fetch path the
counter now measures. Inline comment explains WHY non-force inside
the cache window intentionally no longer increments.
Generated-by: Claude Code (Opus 4.8)
---
.../phoenix/jdbc/FailoverPhoenixConnection.java | 100 +++++---
.../apache/phoenix/jdbc/HighAvailabilityGroup.java | 84 ++++++
.../apache/phoenix/jdbc/HighAvailabilityUtil.java | 34 +++
.../phoenix/monitoring/GlobalClientMetrics.java | 19 ++
.../org/apache/phoenix/monitoring/MetricType.java | 29 +++
.../phoenix/util/GetClusterRoleRecordUtil.java | 16 ++
.../phoenix/hbase/index/IndexRegionObserver.java | 19 ++
.../hbase/index/metrics/MetricsHaBypassSource.java | 77 ++++++
.../metrics/MetricsHaBypassSourceFactory.java | 42 +++
.../index/metrics/MetricsHaBypassSourceImpl.java | 67 +++++
.../index/BypassedMutationBlockMetricsIT.java | 83 ++++++
.../org/apache/phoenix/jdbc/HAGroupMetricsIT.java | 285 +++++++++++++++++++++
.../metrics/MetricsHaBypassSourceFactoryTest.java | 44 ++++
.../phoenix/jdbc/HighAvailabilityGroupTest.java | 62 +++++
.../phoenix/jdbc/HighAvailabilityUtilTest.java | 71 +++++
15 files changed, 990 insertions(+), 42 deletions(-)
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/FailoverPhoenixConnection.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/FailoverPhoenixConnection.java
index 58c79c49e9..5916ea86c4 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/FailoverPhoenixConnection.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/FailoverPhoenixConnection.java
@@ -17,7 +17,11 @@
*/
package org.apache.phoenix.jdbc;
+import static
org.apache.phoenix.jdbc.HighAvailabilityUtil.isMutationBlockedIOExceptionExistsInThrowable;
import static
org.apache.phoenix.jdbc.HighAvailabilityUtil.isStaleClusterRoleRecordExceptionExistsInThrowable;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_FAILOVER_DURATION_MS;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_MUTATION_BLOCKED_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_STALE_CRR_DETECTED_COUNT;
import java.sql.Array;
import java.sql.Blob;
@@ -171,55 +175,63 @@ public class FailoverPhoenixConnection implements
PhoenixMonitoredConnection {
return;
}
- PhoenixConnection newConn = null;
- SQLException cause = null;
- final long startTime = EnvironmentEdgeManager.currentTimeMillis();
- while (newConn == null && EnvironmentEdgeManager.currentTimeMillis() <
startTime + timeoutMs) {
- try {
- newConn =
- context.getHAGroup().connectActive(context.getProperties(),
context.getHAURLInfo());
- } catch (SQLException e) {
- cause = e;
- LOG.info("Got exception when trying to connect to active cluster.", e);
+ final long failoverStartMs = EnvironmentEdgeManager.currentTimeMillis();
+ try {
+ PhoenixConnection newConn = null;
+ SQLException cause = null;
+ final long startTime = EnvironmentEdgeManager.currentTimeMillis();
+ while (
+ newConn == null && EnvironmentEdgeManager.currentTimeMillis() <
startTime + timeoutMs
+ ) {
try {
- Thread.sleep(100); // TODO: be smart than this
- } catch (InterruptedException ie) {
- Thread.currentThread().interrupt();
- throw new SQLException("Got interrupted waiting for connection
failover", e);
+ newConn =
+ context.getHAGroup().connectActive(context.getProperties(),
context.getHAURLInfo());
+ } catch (SQLException e) {
+ cause = e;
+ LOG.info("Got exception when trying to connect to active cluster.",
e);
+ try {
+ Thread.sleep(100); // TODO: be smart than this
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new SQLException("Got interrupted waiting for connection
failover", e);
+ }
}
}
- }
- if (newConn == null) {
- throw new FailoverSQLException("Can not failover connection",
- context.getHAGroup().getGroupInfo().toString(), cause);
- }
+ if (newConn == null) {
+ throw new FailoverSQLException("Can not failover connection",
+ context.getHAGroup().getGroupInfo().toString(), cause);
+ }
- final PhoenixConnection oldConn = connection;
- connection = newConn;
- if (oldConn != null) {
- // aggregate metrics
- previousMutationMetrics = oldConn.getMutationMetrics();
- previousReadMetrics = oldConn.getReadMetrics();
- oldConn.clearMetrics();
-
- // close old connection
- if (!oldConn.isClosed()) {
- // TODO: what happens to in-flight edits/mutations?
- // Can we copy into the new connection we do not allow this failover?
- // MutationState state = oldConn.getMutationState();
- try {
- oldConn.close(new
SQLExceptionInfo.Builder(SQLExceptionCode.HA_CLOSED_AFTER_FAILOVER)
- .setMessage("Phoenix connection got closed due to failover")
-
.setHaGroupInfo(context.getHAGroup().getGroupInfo().toString()).build()
- .buildException());
- } catch (SQLException e) {
- LOG.error("Failed to close old connection after failover: {}",
e.getMessage());
- LOG.info("Full stack when closing old connection after failover", e);
+ final PhoenixConnection oldConn = connection;
+ connection = newConn;
+ if (oldConn != null) {
+ // aggregate metrics
+ previousMutationMetrics = oldConn.getMutationMetrics();
+ previousReadMetrics = oldConn.getReadMetrics();
+ oldConn.clearMetrics();
+
+ // close old connection
+ if (!oldConn.isClosed()) {
+ // TODO: what happens to in-flight edits/mutations?
+ // Can we copy into the new connection we do not allow this failover?
+ // MutationState state = oldConn.getMutationState();
+ try {
+ oldConn.close(new
SQLExceptionInfo.Builder(SQLExceptionCode.HA_CLOSED_AFTER_FAILOVER)
+ .setMessage("Phoenix connection got closed due to failover")
+
.setHaGroupInfo(context.getHAGroup().getGroupInfo().toString()).build()
+ .buildException());
+ } catch (SQLException e) {
+ LOG.error("Failed to close old connection after failover: {}",
e.getMessage());
+ LOG.info("Full stack when closing old connection after failover",
e);
+ }
}
}
+ LOG.info("Connection {} failed over to {}",
context.getHAGroup().getGroupInfo(),
+ connection.getURL());
+ } finally {
+ GLOBAL_HA_FAILOVER_DURATION_MS
+ .update(EnvironmentEdgeManager.currentTimeMillis() - failoverStartMs);
}
- LOG.info("Connection {} failed over to {}",
context.getHAGroup().getGroupInfo(),
- connection.getURL());
}
/**
@@ -324,6 +336,7 @@ public class FailoverPhoenixConnection implements
PhoenixMonitoredConnection {
return s.get();
} catch (Exception e) {
if (isStaleClusterRoleRecordExceptionExistsInThrowable(e)) {
+ GLOBAL_HA_STALE_CRR_DETECTED_COUNT.increment();
// If we receive StaleClusterRoleRecordException, that means
Operation was
// supposed to be executed on Active Cluster but was in reality was
sent to
// STANDBY Cluster, that can happen only when Failover is in
Progress, So we
@@ -348,6 +361,9 @@ public class FailoverPhoenixConnection implements
PhoenixMonitoredConnection {
context.getHAGroup(), e))
.build().buildException();
}
+ if (isMutationBlockedIOExceptionExistsInThrowable(e)) {
+ GLOBAL_HA_MUTATION_BLOCKED_COUNT.increment();
+ }
if (policy.shouldFailover(e, ++failoverCount)) {
failover(timeoutMs);
} else {
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 29dbf21dc8..017f996a31 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
@@ -17,6 +17,9 @@
*/
package org.apache.phoenix.jdbc;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_CRR_CACHE_AGE_MS;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_CRR_REFRESH_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_FAILOVER_COUNT;
import static
org.apache.phoenix.query.QueryServicesOptions.DEFAULT_CLIENT_CONNECTION_CACHE_MAX_DURATION;
import static org.apache.phoenix.util.PhoenixRuntime.JDBC_PROTOCOL_SEPARATOR;
@@ -629,6 +632,7 @@ public class HighAvailabilityGroup {
LOG.info("Initial cluster role for HA group {} is {}", info,
roleRecordFromEndpoint);
roleRecord = roleRecordFromEndpoint;
+ lastClusterRoleRecordRefreshTime = System.currentTimeMillis();
state = State.READY;
}
@@ -644,6 +648,11 @@ public class HighAvailabilityGroup {
.setMessage("HA group is not
ready!").setHaGroupInfo(info.toString()).build()
.buildException();
}
+ // GAUGE: most-recent-sample of "milliseconds since the last successful
CRR refresh".
+ // Use set(...) to overwrite the prior sample; do NOT increment/update —
that would turn
+ // the gauge into an accumulator and break "current age" semantics.
+ GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric()
+ .set(System.currentTimeMillis() - lastClusterRoleRecordRefreshTime);
return roleRecord.getPolicy().provide(this, properties, haurlInfo);
}
@@ -1065,7 +1074,14 @@ public class HighAvailabilityGroup {
}
ClusterRoleRecord newRoleRecord = getClusterRoleRecordFromEndpoint();
+ // Count only refreshes that actually fetched a CRR from the endpoint.
Callers that
+ // short-circuit on shouldRefreshRoleRecord() above never touch the
network and would
+ // otherwise inflate this counter against its name (a "refresh" with no
fetch is a no-op
+ // from a CRR-state perspective).
+ GLOBAL_HA_CRR_REFRESH_COUNT.increment();
if (roleRecord == null) {
+ // First-load init path: no prior cache state to compare against, so
this is not a
+ // failover transition and HA_FAILOVER_COUNT is intentionally NOT
incremented here.
roleRecord = newRoleRecord;
lastClusterRoleRecordRefreshTime = System.currentTimeMillis();
state = State.READY;
@@ -1106,8 +1122,10 @@ public class HighAvailabilityGroup {
long maxTransitionTimeMs = StringUtils.isNotEmpty(transitionTimeoutProp)
? Long.parseLong(transitionTimeoutProp)
: PHOENIX_HA_TRANSITION_TIMEOUT_MS_DEFAULT;
+ boolean transitionSucceeded = false;
try {
future.get(maxTransitionTimeMs, TimeUnit.MILLISECONDS);
+ transitionSucceeded = true;
} catch (InterruptedException ie) {
LOG.error("Got interrupted when transiting cluster roles for HA group
{}", info, ie);
future.cancel(true);
@@ -1133,6 +1151,17 @@ public class HighAvailabilityGroup {
// The goal here is to gain higher availability even though existing
resources against
// previous ACTIVE cluster may have not been closed cleanly.
}
+ // Count the transition as a failover only when the policy-side
transition actually
+ // succeeded AND an active cluster is established or moves between
peers. Operator-driven
+ // transitions to a no-active state (both clusters STANDBY) are not
counted as failovers;
+ // recovery from no-active back to having an ACTIVE peer is counted.
Transitions where
+ // future.get() failed (ExecutionException/TimeoutException) are
best-effort fall-through
+ // per the comment above, but they are NOT counted as successful
failovers. Gate decision
+ // factored into the package-private static {@link #shouldCountFailover}
so it can be
+ // unit-tested directly without driving a full mini-cluster transition.
+ if (shouldCountFailover(transitionSucceeded, oldRecord, newRoleRecord)) {
+ GLOBAL_HA_FAILOVER_COUNT.increment();
+ }
// Update the role record and the last refresh time
roleRecord = newRoleRecord;
lastClusterRoleRecordRefreshTime = System.currentTimeMillis();
@@ -1156,4 +1185,59 @@ public class HighAvailabilityGroup {
long cacheAge = System.currentTimeMillis() -
lastClusterRoleRecordRefreshTime;
return cacheAge >= clusterRoleRecordCacheFrequency;
}
+
+ /**
+ * Returns the current age, in milliseconds, of the last successful
ClusterRoleRecord refresh
+ * (i.e. {@code now - lastClusterRoleRecordRefreshTime}). Intended for
periodic sampling by
+ * external callers (e.g. the CRR poller) so the {@code HA_CRR_CACHE_AGE_MS}
counter-backed gauge
+ * reflects current age without waiting for the next {@link #connect} call.
+ * <p>
+ * Returns the sentinel value <strong>{@code -1L} when no refresh has
occurred yet</strong> (i.e.
+ * {@code lastClusterRoleRecordRefreshTime == 0}). This disambiguates "never
sampled" from
+ * "refreshed within the same millisecond" — both of which would otherwise
return {@code 0L} and
+ * be indistinguishable on the gauge. Dashboards and alert rules should
treat {@code -1L} as "no
+ * data yet" and gate threshold checks on {@code value >= 0}.
+ * <p>
+ * The {@code -1L} sentinel also supersedes a latent bug in the prior {@code
connect()}-only
+ * gauge-publish path: because the CRR poller is scheduled with an initial
delay of {@code 0} (see
+ * {@code GetClusterRoleRecordUtil#schedulePoller}) it can fire its first
tick before
+ * {@link #init} has assigned {@code lastClusterRoleRecordRefreshTime}, in
which case the raw
+ * arithmetic {@code now - 0} would publish a giant value (~{@code
currentTimeMillis()}) to the
+ * gauge and spuriously trip every "age > threshold" alert. Returning {@code
-1L} during that race
+ * window publishes a clean "not yet sampled" marker instead.
+ * <p>
+ * Do NOT change this back to {@code return 0L}: the connect()-site at line
~654 still uses raw
+ * arithmetic (it is state-gated and unreachable before {@code init()} seeds
the timestamp), so
+ * the {@code -1L} sentinel only ever surfaces through the poller-tick
sample path.
+ */
+ public long getCacheAgeMs() {
+ if (lastClusterRoleRecordRefreshTime == 0) {
+ return -1L;
+ }
+ return System.currentTimeMillis() - lastClusterRoleRecordRefreshTime;
+ }
+
+ /**
+ * Decides whether a CRR transition counts as a "failover" for {@code
HA_FAILOVER_COUNT} purposes.
+ * Returns {@code true} iff:
+ * <ol>
+ * <li>the policy-side transition (via {@code future.get(timeout)}) actually
succeeded — a
+ * {@code TimeoutException} / {@code ExecutionException} fall-through is NOT
a failover, AND</li>
+ * <li>the active-cluster URL actually moved between peers (or recovered
from no-active), AND</li>
+ * <li>the new record has an ACTIVE cluster — transitions INTO a no-active
state are not
+ * failovers.</li>
+ * </ol>
+ * Pure function of its inputs (no global state read, no clock read) so it
is straightforward to
+ * unit-test the gate without driving a full mini-cluster transition.
Package-private rather than
+ * private so {@code HighAvailabilityGroupTest} can call it directly.
+ * @param transitionSucceeded whether {@code
future.get(maxTransitionTimeMs)} returned normally
+ * @param oldRecord the previous {@link ClusterRoleRecord}
+ * @param newRecord the candidate new {@link ClusterRoleRecord}
+ * @return whether to increment {@code HA_FAILOVER_COUNT} for this transition
+ */
+ static boolean shouldCountFailover(boolean transitionSucceeded,
ClusterRoleRecord oldRecord,
+ ClusterRoleRecord newRecord) {
+ return transitionSucceeded &&
!oldRecord.getActiveUrl().equals(newRecord.getActiveUrl())
+ && newRecord.getActiveUrl().isPresent();
+ }
}
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityUtil.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityUtil.java
index 1893ad3169..386529a6a7 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityUtil.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityUtil.java
@@ -18,6 +18,7 @@
package org.apache.phoenix.jdbc;
import org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException;
+import org.apache.phoenix.exception.MutationBlockedIOException;
import org.apache.phoenix.exception.StaleClusterRoleRecordException;
/**
@@ -45,7 +46,40 @@ public class HighAvailabilityUtil {
return false;
}
+ /**
+ * Walks the throwable chain (including {@link
RetriesExhaustedWithDetailsException} causes) to
+ * detect a {@link MutationBlockedIOException} surface. Mirrors the
structure of
+ * {@link #isStaleClusterRoleRecordExceptionExistsInThrowable(Throwable)} so
that batched mutation
+ * rejections wrapped at varying depth are still attributable to the
mutation-block gate.
+ */
+ public static boolean
isMutationBlockedIOExceptionExistsInThrowable(Throwable e) {
+ if (e == null) {
+ return false;
+ }
+ if (isGivenThrowableMutationBlockedException(e)) {
+ return true;
+ }
+
+ if (e instanceof RetriesExhaustedWithDetailsException) {
+ for (Throwable t : ((RetriesExhaustedWithDetailsException)
e).getCauses()) {
+ if (isGivenThrowableMutationBlockedException(t)) {
+ return true;
+ }
+ }
+ }
+
+ if (e.getCause() != null) {
+ return isMutationBlockedIOExceptionExistsInThrowable(e.getCause());
+ }
+
+ return false;
+ }
+
private static boolean isGivenThrowableStaleException(Throwable t) {
return t instanceof StaleClusterRoleRecordException;
}
+
+ private static boolean isGivenThrowableMutationBlockedException(Throwable t)
{
+ return t instanceof MutationBlockedIOException;
+ }
}
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/GlobalClientMetrics.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/GlobalClientMetrics.java
index 89f04f8827..bbd23bcbc5 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/GlobalClientMetrics.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/GlobalClientMetrics.java
@@ -35,6 +35,11 @@ import static
org.apache.phoenix.monitoring.MetricType.COUNT_ROWS_SCANNED;
import static org.apache.phoenix.monitoring.MetricType.COUNT_RPC_CALLS;
import static org.apache.phoenix.monitoring.MetricType.COUNT_RPC_RETRIES;
import static org.apache.phoenix.monitoring.MetricType.COUNT_SCANNED_REGIONS;
+import static org.apache.phoenix.monitoring.MetricType.HA_CRR_CACHE_AGE_MS;
+import static org.apache.phoenix.monitoring.MetricType.HA_CRR_REFRESH_COUNT;
+import static org.apache.phoenix.monitoring.MetricType.HA_FAILOVER_COUNT;
+import static org.apache.phoenix.monitoring.MetricType.HA_FAILOVER_DURATION_MS;
+import static
org.apache.phoenix.monitoring.MetricType.HA_MUTATION_BLOCKED_COUNT;
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_CONNECTION_CREATED_COUNTER;
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_CONNECTION_ERROR_COUNTER;
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_CONNECTION_FALLBACK_COUNTER;
@@ -49,6 +54,9 @@ import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_POOL2_TASK_EX
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_POOL2_TASK_QUEUE_WAIT_TIME;
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_POOL2_TASK_REJECTED_COUNTER;
import static
org.apache.phoenix.monitoring.MetricType.HA_PARALLEL_TASK_TIMEOUT_COUNTER;
+import static org.apache.phoenix.monitoring.MetricType.HA_POLLER_TICK_COUNT;
+import static org.apache.phoenix.monitoring.MetricType.HA_POLLER_TICK_FAILURES;
+import static
org.apache.phoenix.monitoring.MetricType.HA_STALE_CRR_DETECTED_COUNT;
import static org.apache.phoenix.monitoring.MetricType.HCONNECTIONS_COUNTER;
import static
org.apache.phoenix.monitoring.MetricType.INDEX_COMMIT_FAILURE_SIZE;
import static org.apache.phoenix.monitoring.MetricType.MEMORY_CHUNK_BYTES;
@@ -163,6 +171,17 @@ public enum GlobalClientMetrics {
GLOBAL_HA_PARALLEL_CONNECTION_ERROR_COUNTER(HA_PARALLEL_CONNECTION_ERROR_COUNTER),
GLOBAL_HA_PARALLEL_CONNECTION_CREATED_COUNTER(HA_PARALLEL_CONNECTION_CREATED_COUNTER),
+ GLOBAL_HA_FAILOVER_COUNT(HA_FAILOVER_COUNT),
+ GLOBAL_HA_FAILOVER_DURATION_MS(HA_FAILOVER_DURATION_MS),
+ GLOBAL_HA_MUTATION_BLOCKED_COUNT(HA_MUTATION_BLOCKED_COUNT),
+ GLOBAL_HA_STALE_CRR_DETECTED_COUNT(HA_STALE_CRR_DETECTED_COUNT),
+ GLOBAL_HA_CRR_REFRESH_COUNT(HA_CRR_REFRESH_COUNT),
+ // GAUGE: most-recent-sample. Use getMetric().set(ageMs) at the sampling
site;
+ // do NOT increment or update — that would accumulate and break gauge
semantics.
+ GLOBAL_HA_CRR_CACHE_AGE_MS(HA_CRR_CACHE_AGE_MS),
+ GLOBAL_HA_POLLER_TICK_COUNT(HA_POLLER_TICK_COUNT),
+ GLOBAL_HA_POLLER_TICK_FAILURES(HA_POLLER_TICK_FAILURES),
+
GLOBAL_CLIENT_METADATA_CACHE_MISS_COUNTER(CLIENT_METADATA_CACHE_MISS_COUNTER),
GLOBAL_CLIENT_METADATA_CACHE_HIT_COUNTER(CLIENT_METADATA_CACHE_HIT_COUNTER),
GLOBAL_CLIENT_METADATA_CACHE_EVICTION_COUNTER(CLIENT_METADATA_CACHE_EVICTION_COUNTER),
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/MetricType.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/MetricType.java
index ff80705c0d..22a9ecbdcf 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/MetricType.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/monitoring/MetricType.java
@@ -353,6 +353,35 @@ public enum MetricType {
LogLevel.DEBUG, PLong.INSTANCE),
HA_PARALLEL_CONNECTION_CREATED_COUNTER("hpccc",
"Counter for the number of parallel phoenix connections that were
created", LogLevel.DEBUG,
+ PLong.INSTANCE),
+ HA_FAILOVER_COUNT("hafc",
+ "Counter for cluster-level failover transitions (active URL flips between
peers or recovers "
+ + "from no-active state) recorded at the CRR write site",
+ LogLevel.DEBUG, PLong.INSTANCE),
+ HA_FAILOVER_DURATION_MS("hafd",
+ "Total time in milliseconds spent in connection-level failover
transitions, summed across "
+ + "all observing connections (per-connection observation, not
per-cluster-event)",
+ LogLevel.DEBUG, PLong.INSTANCE),
+ HA_MUTATION_BLOCKED_COUNT("hambc",
+ "Counter for MutationBlockedIOException surfaces caught by
wrapActionDuringFailover",
+ LogLevel.DEBUG, PLong.INSTANCE),
+ HA_STALE_CRR_DETECTED_COUNT("hascd",
+ "Counter for StaleClusterRoleRecordException surfaces caught by
wrapActionDuringFailover",
+ LogLevel.DEBUG, PLong.INSTANCE),
+ HA_CRR_REFRESH_COUNT("hcrc", "Counter for refreshClusterRoleRecord
invocations", LogLevel.DEBUG,
+ PLong.INSTANCE),
+ // GAUGE SEMANTICS: most-recent-sample, NOT an accumulator. Callers MUST use
+ // getMetric().set(ageMs) to overwrite the previous sample, never
increment/update.
+ // Misuse will produce a monotonically-growing accumulator, which is the
wrong shape for an
+ // "age since last refresh" gauge.
+ HA_CRR_CACHE_AGE_MS("hccg",
+ "Gauge: most-recent-sample of milliseconds since the last successful CRR
refresh "
+ + "(use getMetric().set(ageMs))",
+ LogLevel.DEBUG, PLong.INSTANCE),
+ HA_POLLER_TICK_COUNT("hptc", "Counter for non-active CRR poller ticks
executed", LogLevel.DEBUG,
+ PLong.INSTANCE),
+ HA_POLLER_TICK_FAILURES("hptf",
+ "Counter for non-active CRR poller tick failures (caught SQLException)",
LogLevel.DEBUG,
PLong.INSTANCE);
private final String description;
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 e6ac19d9e9..16c7715c17 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
@@ -42,6 +42,7 @@ import org.apache.phoenix.jdbc.ClusterRoleRecord.ClusterRole;
import org.apache.phoenix.jdbc.HighAvailabilityGroup;
import org.apache.phoenix.jdbc.HighAvailabilityPolicy;
import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.monitoring.GlobalClientMetrics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -227,6 +228,20 @@ public class GetClusterRoleRecordUtil {
Runnable pollingTask = () -> {
// Increment unconditionally so a failed tick still alternates next
iteration.
long tick = tickCount.getAndIncrement();
+ GlobalClientMetrics.GLOBAL_HA_POLLER_TICK_COUNT.increment();
+ // Sample current CRR cache age into the gauge each tick. Without
this, the
+ // HA_CRR_CACHE_AGE_MS counter-backed gauge is only updated on
connect() and would
+ // not advance during idle periods between connects, making it look
fresher than it
+ // actually is. Sampling here puts the gauge on a steady wall-clock
cadence matching
+ // the poller's polling interval.
+ try {
+
GlobalClientMetrics.GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric().set(haGroup.getCacheAgeMs());
+ } catch (Throwable t) {
+ // Metric sampling is best-effort; never let a metric write break
the poller tick.
+ LOGGER.warn(
+ "Failed to sample HA_CRR_CACHE_AGE_MS on poller tick for HA group
{}; " + "continuing",
+ haGroupName, t);
+ }
String tickUrl = selectUrlForTick(url1, url2, tick);
try {
ClusterRoleRecord polledCrr =
@@ -253,6 +268,7 @@ public class GetClusterRoleRecordUtil {
}
}
} catch (SQLException e) {
+ GlobalClientMetrics.GLOBAL_HA_POLLER_TICK_FAILURES.increment();
LOGGER.error(
"Exception found while polling for ClusterRoleRecord on {} for HA
group" + " {}: {}",
tickUrl, haGroupName, e.getMessage());
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
index 533c1b4da3..f5063fc7e1 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
@@ -115,6 +115,7 @@ import
org.apache.phoenix.hbase.index.builder.IndexBuildManager;
import org.apache.phoenix.hbase.index.builder.IndexBuilder;
import org.apache.phoenix.hbase.index.covered.IndexMetaData;
import org.apache.phoenix.hbase.index.covered.update.ColumnReference;
+import org.apache.phoenix.hbase.index.metrics.MetricsHaBypassSourceFactory;
import org.apache.phoenix.hbase.index.metrics.MetricsIndexerSource;
import org.apache.phoenix.hbase.index.metrics.MetricsIndexerSourceFactory;
import org.apache.phoenix.hbase.index.table.HTableInterfaceReference;
@@ -746,6 +747,24 @@ public class IndexRegionObserver implements
RegionCoprocessor, RegionObserver {
// Extract HAGroupName from the mutations
Optional<ReplicationLogGroup> logGroup =
getHAGroupFromBatch(c.getEnvironment(), miniBatchOp);
+ // Path-coverage counter: increments whenever a mutation batch reaches
preBatchMutate
+ // without a resolvable HA group attribute, so the cluster-role-based
mutation-block gate
+ // has no haGroupName to evaluate against and is skipped. This counts
the code path being
+ // short-circuited — it does NOT imply any safety property was breached
(when the block
+ // feature is disabled or no block window is active, there is no
property to breach).
+ // Tracked globally rather than per-table so operators can compare
baseline vs.
+ // post-deploy delta to spot new write paths that forgot to attach
_HAGroupName.
+ // Intentionally scoped to !logGroup.isPresent() regardless of
dataTableName —
+ // system-HA-group writes WITH a haGroup are an intended gate exemption
(state writes
+ // must proceed during a block window) and are not counted here.
+ if (!logGroup.isPresent()) {
+ try {
+
MetricsHaBypassSourceFactory.getInstance().incrementBypassedMutationBlockCount();
+ } catch (Throwable t) {
+ LOG.warn("Failed to increment bypassed mutation block count metric;
continuing", t);
+ }
+ }
+
// We don't want to check for mutation blocking for the system ha group
table
if (!dataTableName.equals(SYSTEM_HA_GROUP_NAME) && logGroup.isPresent())
{
// Check if mutation is blocked for the HA Group
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSource.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSource.java
new file mode 100644
index 0000000000..276f1f2bb6
--- /dev/null
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSource.java
@@ -0,0 +1,77 @@
+/*
+ * 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.hbase.index.metrics;
+
+import org.apache.hadoop.hbase.metrics.BaseSource;
+
+/**
+ * Server-side JMX metrics source that counts how many mutation batches pass
through
+ * {@code IndexRegionObserver.preBatchMutate} <em>without</em> a resolvable HA
group attribute, so
+ * the cluster-role-based mutation-block gate has no haGroupName to evaluate
against and is skipped
+ * for that batch.
+ * <p>
+ * This is a <strong>path-coverage detector</strong>, not a safety violation
alarm. The counter is
+ * incremented in {@code IndexRegionObserver.preBatchMutate} for
<em>every</em> mutation batch whose
+ * {@code _HAGroupName} attribute cannot be resolved — regardless of whether
the cluster-role-based
+ * mutation-block feature is enabled, regardless of whether a mutation-block
window is currently
+ * active for any HA group, and regardless of whether the table being written
to is HA-replicated.
+ * The "bypass" terminology refers strictly to the gate-evaluation code path
being short-circuited;
+ * it does <strong>not</strong> imply that any safety property was breached —
when the block feature
+ * is disabled or no block window is active, there is no property to breach in
the first place.
+ * <p>
+ * Intended operator use:
+ * <ul>
+ * <li><strong>Path coverage:</strong> baseline rate tells you what fraction
of mutations on this
+ * RegionServer reach the gate without an HA group attribute. A sustained
baseline of zero on an
+ * HA-enabled cluster suggests the gate is effectively dead code on that path;
a sustained non-zero
+ * baseline tells you those write paths exist and need to be either tagged or
consciously
+ * exempted.</li>
+ * <li><strong>Regression signal:</strong> a <em>delta</em> against baseline
(especially after a
+ * deploy that added a new mutation path) is the actionable signal — it
indicates a newly introduced
+ * write path is not tagging mutations with {@code _HAGroupName}.</li>
+ * </ul>
+ * The absolute value alone is not actionable; pair it with deploy markers and
the
+ * mutation-block-enabled config to interpret correctly.
+ */
+public interface MetricsHaBypassSource extends BaseSource {
+
+ String METRICS_NAME = "HaBypass";
+ String METRICS_CONTEXT = "phoenix";
+ String METRICS_DESCRIPTION =
+ "Metrics for cluster-role-based mutation-block gate path-coverage on the
RegionServer";
+ String METRICS_JMX_CONTEXT = "RegionServer,sub=" + METRICS_NAME;
+
+ String BYPASSED_MUTATION_BLOCK_COUNT = "bypassedMutationBlockCount";
+ String BYPASSED_MUTATION_BLOCK_COUNT_DESC =
+ "Path-coverage counter: number of mutation batches that reached
preBatchMutate without a "
+ + "resolvable _HAGroupName attribute (so the cluster-role-based
mutation-block gate had "
+ + "nothing to evaluate against and was skipped). Counts the code path
being skipped, not "
+ + "a safety breach — when the block feature is disabled or no block
window is active, "
+ + "there is no property to breach. Actionable signal is
delta-against-baseline (e.g., a "
+ + "spike after a deploy introducing a new mutation path), not absolute
value";
+
+ /**
+ * Increments the gate-skipped-path counter. Called unconditionally from
+ * {@code IndexRegionObserver.preBatchMutate} whenever the resolved
+ * {@code Optional<ReplicationLogGroup>} is empty — i.e., the mutation batch
carries no
+ * {@code _HAGroupName} attribute and the cluster-role-based mutation-block
gate cannot be
+ * evaluated for it. Independent of whether the mutation-block feature is
enabled or any block
+ * window is active.
+ */
+ void incrementBypassedMutationBlockCount();
+}
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactory.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactory.java
new file mode 100644
index 0000000000..66e6f3aadd
--- /dev/null
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactory.java
@@ -0,0 +1,42 @@
+/*
+ * 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.hbase.index.metrics;
+
+/**
+ * Factory for the per-RegionServer {@link MetricsHaBypassSource} singleton.
Unlike the per-haGroup
+ * factories elsewhere in the codebase, the bypass counter is a single global
(per-JVM,
+ * per-RegionServer) counter, so this factory holds one eagerly-initialized
instance rather than a
+ * {@code ConcurrentHashMap} keyed on haGroupName.
+ */
+public final class MetricsHaBypassSourceFactory {
+
+ private static final MetricsHaBypassSource INSTANCE = new
MetricsHaBypassSourceImpl();
+
+ private MetricsHaBypassSourceFactory() {
+ }
+
+ /**
+ * Returns the process-wide {@link MetricsHaBypassSource} singleton. The
instance is initialized
+ * eagerly at class-load time, so this method is thread-safe without any
additional
+ * synchronization.
+ * @return the singleton {@link MetricsHaBypassSource} for this RegionServer
JVM
+ */
+ public static MetricsHaBypassSource getInstance() {
+ return INSTANCE;
+ }
+}
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceImpl.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceImpl.java
new file mode 100644
index 0000000000..5963b11fae
--- /dev/null
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceImpl.java
@@ -0,0 +1,67 @@
+/*
+ * 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.hbase.index.metrics;
+
+import org.apache.hadoop.hbase.metrics.BaseSourceImpl;
+import org.apache.hadoop.metrics2.lib.MutableFastCounter;
+
+/**
+ * Implementation of {@link MetricsHaBypassSource} backed by a single {@link
MutableFastCounter} on
+ * the RegionServer JMX registry. The counter is per-RegionServer (not keyed
by HA group, by design
+ * — a bypass event has no haGroupName to attribute against).
+ */
+public class MetricsHaBypassSourceImpl extends BaseSourceImpl implements
MetricsHaBypassSource {
+
+ private final MutableFastCounter bypassedMutationBlockCount;
+
+ /**
+ * Default constructor used by {@link MetricsHaBypassSourceFactory}.
Registers the source under
+ * the standard {@code HaBypass} JMX context shared with operator dashboards.
+ */
+ public MetricsHaBypassSourceImpl() {
+ this(METRICS_NAME, METRICS_DESCRIPTION, METRICS_CONTEXT,
METRICS_JMX_CONTEXT);
+ }
+
+ /**
+ * Test-friendly constructor that lets callers override the metrics-registry
naming, so unit tests
+ * can register an isolated source without colliding with the production
singleton.
+ * @param metricsName short name reported to the metrics registry
+ * @param metricsDescription human-readable description for the registry
+ * @param metricsContext hadoop-metrics2 context name
+ * @param metricsJmxContext JMX context (typically {@code
"RegionServer,sub=" + metricsName})
+ */
+ public MetricsHaBypassSourceImpl(String metricsName, String
metricsDescription,
+ String metricsContext, String metricsJmxContext) {
+ super(metricsName, metricsDescription, metricsContext, metricsJmxContext);
+ bypassedMutationBlockCount =
getMetricsRegistry().newCounter(BYPASSED_MUTATION_BLOCK_COUNT,
+ BYPASSED_MUTATION_BLOCK_COUNT_DESC, 0L);
+ }
+
+ @Override
+ public void incrementBypassedMutationBlockCount() {
+ bypassedMutationBlockCount.incr();
+ }
+
+ /**
+ * Test-only accessor returning the current counter value. Public so
unit/integration tests
+ * outside this package can assert increments without going through JMX read
scaffolding.
+ */
+ public long getBypassedMutationBlockCountForTesting() {
+ return bypassedMutationBlockCount.value();
+ }
+}
diff --git
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BypassedMutationBlockMetricsIT.java
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BypassedMutationBlockMetricsIT.java
new file mode 100644
index 0000000000..4db777d287
--- /dev/null
+++
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BypassedMutationBlockMetricsIT.java
@@ -0,0 +1,83 @@
+/*
+ * 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.end2end.index;
+
+import static org.apache.phoenix.query.BaseTest.generateUniqueName;
+import static
org.apache.phoenix.query.QueryServices.CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.util.Properties;
+import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
+import org.apache.phoenix.hbase.index.metrics.MetricsHaBypassSourceFactory;
+import org.apache.phoenix.hbase.index.metrics.MetricsHaBypassSourceImpl;
+import org.apache.phoenix.jdbc.HABaseIT;
+import org.apache.phoenix.jdbc.PhoenixDriver;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Integration test for the server-side {@code bypassedMutationBlockCount} JMX
counter. A "bypass"
+ * is a mutation batch that reaches {@code IndexRegionObserver.preBatchMutate}
without an associated
+ * HA group attribute, causing the cluster-role-based mutation-block gate to
be skipped. This IT
+ * drives a write that does not carry {@code _HAGroupName} and asserts the
counter increments.
+ */
+@Category(NeedsOwnMiniClusterTest.class)
+public class BypassedMutationBlockMetricsIT extends HABaseIT {
+
+ @BeforeClass
+ public static synchronized void doSetup() throws Exception {
+ CLUSTERS.getHBaseCluster1().getConfiguration()
+ .setBoolean(CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED, true);
+ CLUSTERS.getHBaseCluster2().getConfiguration()
+ .setBoolean(CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED, true);
+ CLUSTERS.start();
+ DriverManager.registerDriver(PhoenixDriver.INSTANCE);
+ }
+
+ @Test(timeout = 300000)
+ public void testBypassedMutationBlockCount() throws Exception {
+ MetricsHaBypassSourceImpl source =
+ (MetricsHaBypassSourceImpl) MetricsHaBypassSourceFactory.getInstance();
+ long before = source.getBypassedMutationBlockCountForTesting();
+
+ String dataTableName = generateUniqueName();
+ String indexName = generateUniqueName();
+
+ // Connect directly to cluster1 (single-cluster JDBC URL — no _HAGroupName
attribute).
+ // Use the master/RPC address rather than the ZK url so the rpc URL parser
doesn't
+ // choke on the `=` token that ZK urls embed.
+ Properties props = new Properties();
+ try (Connection conn =
+
DriverManager.getConnection(CLUSTERS.getJdbcUrl(CLUSTERS.getMasterAddress1()),
props)) {
+ conn.createStatement().execute(
+ "CREATE TABLE " + dataTableName + " (id VARCHAR PRIMARY KEY, name
VARCHAR, age INTEGER)");
+ conn.createStatement()
+ .execute("CREATE INDEX " + indexName + " ON " + dataTableName +
"(name)");
+ conn.createStatement().execute("UPSERT INTO " + dataTableName + " VALUES
('1', 'A', 1)");
+ conn.commit();
+ }
+
+ long after = source.getBypassedMutationBlockCountForTesting();
+ assertTrue(
+ "bypassedMutationBlockCount should increment for mutations without
_HAGroupName attribute",
+ after > before);
+ }
+}
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
new file mode 100644
index 0000000000..088ccff183
--- /dev/null
+++ b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupMetricsIT.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.jdbc;
+
+import static
org.apache.phoenix.jdbc.HighAvailabilityGroup.PHOENIX_HA_GROUP_ATTR;
+import static
org.apache.phoenix.jdbc.HighAvailabilityTestingUtility.getHighAvailibilityGroup;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_CRR_CACHE_AGE_MS;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_CRR_REFRESH_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_FAILOVER_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_FAILOVER_DURATION_MS;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_MUTATION_BLOCKED_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_POLLER_TICK_COUNT;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_POLLER_TICK_FAILURES;
+import static
org.apache.phoenix.monitoring.GlobalClientMetrics.GLOBAL_HA_STALE_CRR_DETECTED_COUNT;
+import static
org.apache.phoenix.query.QueryServices.CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Properties;
+import org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException;
+import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
+import org.apache.phoenix.exception.MutationBlockedIOException;
+import org.apache.phoenix.exception.SQLExceptionCode;
+import org.apache.phoenix.execute.CommitException;
+import org.apache.phoenix.jdbc.ClusterRoleRecord.ClusterRole;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.TestName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Integration test for the client-side HA observability metrics. Each test
resets the relevant
+ * {@link org.apache.phoenix.monitoring.GlobalClientMetrics} entries before
exercising the code path
+ * that should emit them, then asserts the post-condition counter/gauge value.
+ */
+@Category(NeedsOwnMiniClusterTest.class)
+public class HAGroupMetricsIT extends HABaseIT {
+ private static final Logger LOG =
LoggerFactory.getLogger(HAGroupMetricsIT.class);
+
+ @Rule
+ public final TestName testName = new TestName();
+
+ private Properties clientProperties;
+ private HighAvailabilityGroup haGroup;
+ private String tableName;
+ private String haGroupName;
+
+ @BeforeClass
+ public static void setUpBeforeClass() throws Exception {
+ CLUSTERS.getHBaseCluster1().getConfiguration()
+ .setBoolean(CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED, true);
+ CLUSTERS.getHBaseCluster2().getConfiguration()
+ .setBoolean(CLUSTER_ROLE_BASED_MUTATION_BLOCK_ENABLED, true);
+ CLUSTERS.start();
+ DriverManager.registerDriver(PhoenixDriver.INSTANCE);
+ }
+
+ @AfterClass
+ public static void tearDownAfterClass() throws Exception {
+ DriverManager.deregisterDriver(PhoenixDriver.INSTANCE);
+ CLUSTERS.close();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ haGroupName = testName.getMethodName();
+ clientProperties = HighAvailabilityTestingUtility.getHATestProperties();
+ clientProperties.setProperty(PHOENIX_HA_GROUP_ATTR, haGroupName);
+ CLUSTERS.initClusterRole(haGroupName, HighAvailabilityPolicy.FAILOVER);
+ haGroup = getHighAvailibilityGroup(CLUSTERS.getJdbcHAUrl(),
clientProperties);
+ tableName = testName.getMethodName().toUpperCase();
+ CLUSTERS.createTableOnClusterPair(haGroup, tableName);
+ resetAllHaMetrics();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ try {
+ haGroup.close();
+ } catch (Exception e) {
+ LOG.error("Fail to tear down HA group; ignoring", e);
+ }
+ }
+
+ @Test(timeout = 300000)
+ public void testFailoverCountAndDuration() throws Exception {
+ long countBefore = GLOBAL_HA_FAILOVER_COUNT.getMetric().getValue();
+ long durationBefore =
GLOBAL_HA_FAILOVER_DURATION_MS.getMetric().getValue();
+
+ try (Connection conn =
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProperties)) {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES (1,
1)");
+ conn.commit();
+ CLUSTERS.transitClusterRole(haGroup, ClusterRole.STANDBY,
ClusterRole.ACTIVE);
+ }
+
+ long countAfter = GLOBAL_HA_FAILOVER_COUNT.getMetric().getValue();
+ long durationAfter = GLOBAL_HA_FAILOVER_DURATION_MS.getMetric().getValue();
+ assertTrue("HA_FAILOVER_COUNT should increment on cluster role transition",
+ countAfter > countBefore);
+ assertTrue("HA_FAILOVER_DURATION_MS sum should grow on transition",
+ durationAfter >= durationBefore);
+ }
+
+ @Test(timeout = 300000)
+ public void testStaleCrrDetectedCount() throws Exception {
+ long before = GLOBAL_HA_STALE_CRR_DETECTED_COUNT.getMetric().getValue();
+ // Drive a cluster-role transition that the wrapped connection observes as
+ // stale-CRR — failover() flags STALE_CLUSTER_ROLE_RECORD and increments
the counter.
+ try (Connection conn =
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProperties)) {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES (2,
2)");
+ conn.commit();
+ CLUSTERS.transitClusterRole(haGroup, ClusterRole.ACTIVE_TO_STANDBY,
ClusterRole.STANDBY);
+ // doRefreshHAGroup=false: keep haGroup's CRR snapshot stale on purpose
so that
+ // the next mutation drives StaleClusterRoleRecordException through
wrapActionDuringFailover,
+ // exercising the GLOBAL_HA_STALE_CRR_DETECTED_COUNT increment path.
+ CLUSTERS.transitClusterRole(haGroup, ClusterRole.STANDBY,
ClusterRole.ACTIVE, false);
+ // Issue another mutation while the connection still holds the
pre-transition CRR snapshot.
+ // The server's HAGroupStoreManager will detect the version mismatch and
throw
+ // StaleClusterRoleRecordException, which wrapActionDuringFailover
catches to increment the
+ // counter (and rethrow FAILOVER_IN_PROGRESS).
+ try {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES
(3, 3)");
+ conn.commit();
+ } catch (SQLException expected) {
+ // Stale-CRR surfaces here as a SQLException with FAILOVER_IN_PROGRESS
(1990 / F1Q90):
+ // wrapActionDuringFailover catches StaleClusterRoleRecordException,
increments
+ // GLOBAL_HA_STALE_CRR_DETECTED_COUNT, then rethrows a freshly-built
SQLException via
+ //
SQLExceptionInfo.Builder(FAILOVER_IN_PROGRESS).build().buildException(). The
error
+ // code is the contracted surface — any other SQLException must fail
this assertion
+ // rather than be silently swallowed (the prior catch(Exception) hid
setup bugs by
+ // treating ANY failure as the expected path).
+ assertTrue(
+ "Expected FAILOVER_IN_PROGRESS error code, got " +
expected.getErrorCode() + " ("
+ + expected.getSQLState() + ")",
+ expected.getErrorCode() ==
SQLExceptionCode.FAILOVER_IN_PROGRESS.getErrorCode());
+ LOG.info(
+ "Expected FAILOVER_IN_PROGRESS SQLException surfaced from stale-CRR
mutation " + "in {}",
+ testName.getMethodName(), expected);
+ }
+ }
+ long after = GLOBAL_HA_STALE_CRR_DETECTED_COUNT.getMetric().getValue();
+ assertTrue("HA_STALE_CRR_DETECTED_COUNT should strictly increment when CRR
is detected stale "
+ + "(before=" + before + ", after=" + after + ")", after > before);
+ }
+
+ @Test(timeout = 300000)
+ public void testMutationBlockedCount() throws Exception {
+ long before = GLOBAL_HA_MUTATION_BLOCKED_COUNT.getMetric().getValue();
+ String peerZkUrl = CLUSTERS.getZkUrl2();
+ PhoenixHAAdmin haAdmin = CLUSTERS.getHaAdmin1();
+
+ try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection)
DriverManager
+ .getConnection(CLUSTERS.getJdbcHAUrl(), clientProperties)) {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES (3,
3)");
+ conn.commit();
+
+ HAGroupStoreRecord blocking =
+ new HAGroupStoreRecord(HAGroupStoreRecord.DEFAULT_PROTOCOL_VERSION,
haGroupName,
+ HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC_TO_STANDBY, 0L,
+ HighAvailabilityPolicy.FAILOVER.toString(), peerZkUrl,
CLUSTERS.getMasterAddress1(),
+ CLUSTERS.getMasterAddress2(), CLUSTERS.getHdfsUrl1(),
CLUSTERS.getHdfsUrl2(), 0L);
+ haAdmin.updateHAGroupStoreRecordInZooKeeper(haGroupName, blocking, -1);
+ Thread.sleep(1000L);
+
+ try {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES
(4, 4)");
+ conn.commit();
+ fail("Expected MutationBlockedIOException during ACTIVE_TO_STANDBY");
+ } catch (CommitException e) {
+ Throwable cause = e.getCause();
+ assertTrue("Expected MutationBlockedIOException in chain",
+ cause instanceof RetriesExhaustedWithDetailsException
+ && ((RetriesExhaustedWithDetailsException) cause)
+ .getCause(0) instanceof MutationBlockedIOException);
+ }
+ }
+ long after = GLOBAL_HA_MUTATION_BLOCKED_COUNT.getMetric().getValue();
+ assertTrue("HA_MUTATION_BLOCKED_COUNT should increment when MBE is
observed", after > before);
+ }
+
+ @Test(timeout = 300000)
+ public void testCrrRefreshCount() throws Exception {
+ long before = GLOBAL_HA_CRR_REFRESH_COUNT.getMetric().getValue();
+ // Force-refresh both calls: HA_CRR_REFRESH_COUNT only increments on a
refresh that
+ // actually fetched a CRR from the endpoint — a non-force refresh inside
the cache window
+ // short-circuits before getClusterRoleRecordFromEndpoint() and
intentionally does NOT
+ // bump the counter (counter measures "fresh fetches", not "refresh
calls").
+ haGroup.refreshClusterRoleRecord(true);
+ haGroup.refreshClusterRoleRecord(true);
+ long after = GLOBAL_HA_CRR_REFRESH_COUNT.getMetric().getValue();
+ assertTrue("HA_CRR_REFRESH_COUNT should increment on each force-refresh",
after - before >= 2);
+ }
+
+ @Test(timeout = 300000)
+ public void testCrrCacheAgeMs() throws Exception {
+ // Pre-refresh assertion: a connect() against a freshly init()-ed HA group
must sample a
+ // bounded age; if init() forgets to seed lastClusterRoleRecordRefreshTime
the gauge will
+ // record currentTimeMillis() - 0 (~1.7e12 ms) and this assertion will
fail.
+ try (Connection conn =
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProperties)) {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES (4,
4)");
+ conn.commit();
+ }
+ long ageBeforeRefresh = GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric().getValue();
+ assertTrue("HA_CRR_CACHE_AGE_MS gauge must be bounded on init path (was "
+ ageBeforeRefresh
+ + " ms, expected < 60_000)", ageBeforeRefresh < 60_000L);
+
+ // Force a refresh so lastClusterRoleRecordRefreshTime is recent, then
sleep so the
+ // gauge sample (taken on connect()) records a non-trivial age.
+ haGroup.refreshClusterRoleRecord(false);
+ Thread.sleep(50L);
+ try (Connection conn =
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProperties)) {
+ conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES (5,
5)");
+ conn.commit();
+ }
+ long ageMs = GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric().getValue();
+ // Gauge holds the most-recent set() sample; should be > 0 and within a
sane bound.
+ assertTrue("HA_CRR_CACHE_AGE_MS gauge should be > 0 after a connect()",
ageMs > 0L);
+ assertTrue("HA_CRR_CACHE_AGE_MS gauge should be < 5 minutes for a fresh HA
group",
+ ageMs < 5 * 60 * 1000L);
+ }
+
+ @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.
+ long beforeTicks = GLOBAL_HA_POLLER_TICK_COUNT.getMetric().getValue();
+ long beforeFailures =
GLOBAL_HA_POLLER_TICK_FAILURES.getMetric().getValue();
+
+ CLUSTERS.transitClusterRole(haGroup, ClusterRole.ACTIVE_TO_STANDBY,
ClusterRole.STANDBY);
+ CLUSTERS.transitClusterRole(haGroup, ClusterRole.STANDBY,
ClusterRole.STANDBY);
+
+ // Allow at least 2 poller ticks at default interval to land.
+ Thread.sleep(15_000L);
+
+ long afterTicks = GLOBAL_HA_POLLER_TICK_COUNT.getMetric().getValue();
+ long afterFailures = GLOBAL_HA_POLLER_TICK_FAILURES.getMetric().getValue();
+ assertTrue("HA_POLLER_TICK_COUNT should advance once poller is scheduled",
+ afterTicks > beforeTicks);
+ // Failures may or may not occur in mini-cluster; just assert
non-decreasing.
+ assertTrue("HA_POLLER_TICK_FAILURES should be monotonic", afterFailures >=
beforeFailures);
+ // Verify the poller-tick gauge sample site fires: by the time we've slept
15s and seen
+ // multiple ticks, init() has long-since seeded
lastClusterRoleRecordRefreshTime, so the
+ // gauge holds a real age sample. >= 0 guards against the -1L
"never-refreshed" sentinel
+ // leaking out via a buggy refactor — if the poller-tick .set() path is
broken or the
+ // sentinel escapes the init/poller race window, this assertion catches it.
+ long ageGauge = GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric().getValue();
+ assertTrue("HA_CRR_CACHE_AGE_MS gauge should hold a non-negative age
sample after poller ticks "
+ + "(got " + ageGauge + ")", ageGauge >= 0L);
+ }
+
+ private void resetAllHaMetrics() {
+ GLOBAL_HA_FAILOVER_COUNT.getMetric().reset();
+ GLOBAL_HA_FAILOVER_DURATION_MS.getMetric().reset();
+ GLOBAL_HA_MUTATION_BLOCKED_COUNT.getMetric().reset();
+ GLOBAL_HA_STALE_CRR_DETECTED_COUNT.getMetric().reset();
+ GLOBAL_HA_CRR_REFRESH_COUNT.getMetric().reset();
+ GLOBAL_HA_CRR_CACHE_AGE_MS.getMetric().reset();
+ GLOBAL_HA_POLLER_TICK_COUNT.getMetric().reset();
+ GLOBAL_HA_POLLER_TICK_FAILURES.getMetric().reset();
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactoryTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactoryTest.java
new file mode 100644
index 0000000000..18110784ea
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/metrics/MetricsHaBypassSourceFactoryTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hbase.index.metrics;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link MetricsHaBypassSourceFactory}. Asserts the eager-init
singleton contract so
+ * a future refactor that swaps the field for a non-singleton (or a
non-thread-safe) shape gets
+ * caught here rather than in production JMX double-registration failures.
+ */
+public class MetricsHaBypassSourceFactoryTest {
+
+ @Test
+ public void testGetInstanceReturnsNonNull() {
+ assertNotNull("getInstance() must return a non-null singleton",
+ MetricsHaBypassSourceFactory.getInstance());
+ }
+
+ @Test
+ public void testGetInstanceIsIdempotent() {
+ MetricsHaBypassSource first = MetricsHaBypassSourceFactory.getInstance();
+ MetricsHaBypassSource second = MetricsHaBypassSourceFactory.getInstance();
+ assertSame("getInstance() must return the same singleton across calls",
first, second);
+ }
+}
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 8f52b5110c..cc58727838 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
@@ -18,11 +18,14 @@
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.assertTrue;
import java.sql.SQLException;
import java.util.Properties;
import org.apache.phoenix.exception.SQLExceptionCode;
+import org.apache.phoenix.jdbc.ClusterRoleRecord.ClusterRole;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -123,4 +126,63 @@ public class HighAvailabilityGroupTest {
private void getAndAssertUrlInfo(String url, String additionalParams) throws
Exception {
getAndAssertUrlInfo(url, additionalParams, null);
}
+
+ /**
+ * Verifies the gate decision for {@code HA_FAILOVER_COUNT} — exercised
directly via the
+ * package-private {@link HighAvailabilityGroup#shouldCountFailover} helper
rather than driving a
+ * mini-cluster transition. Together these cases pin down that the gate (a)
counts a real ACTIVE
+ * URL move, (b) does NOT count a no-op (same active URL), (c) does NOT
count a transition INTO a
+ * no-active state, and (d) does NOT count a transition where the policy
callback failed
+ * ({@code transitionSucceeded == false}, i.e. {@code future.get()} threw
{@code TimeoutException}
+ * / {@code ExecutionException}). The (d) negative-path assertion is the
regression guard: someone
+ * removing the {@code transitionSucceeded &&} clause from
+ * {@code HighAvailabilityGroup#shouldCountFailover} would silently start
counting failed
+ * transitions as successful failovers, and this test would fail.
+ */
+ @Test
+ public void testShouldCountFailoverGate() {
+ String haGroupName = "testShouldCountFailoverGate";
+ String url1 = "host1\\:60010";
+ String url2 = "host2\\:60010";
+
+ ClusterRoleRecord aActiveBStandby = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.ACTIVE, url2,
ClusterRole.STANDBY, 1L);
+ ClusterRoleRecord aStandbyBActive = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.ACTIVE, 2L);
+ ClusterRoleRecord bothStandby = new ClusterRoleRecord(haGroupName,
+ HighAvailabilityPolicy.FAILOVER, url1, ClusterRole.STANDBY, url2,
ClusterRole.STANDBY, 3L);
+
+ // (a) Real active-URL move with a successful policy transition → COUNT.
+ assertTrue(
+ "ACTIVE moving from cluster 1 to cluster 2 with successful policy
transition "
+ + "should count as a failover",
+ HighAvailabilityGroup.shouldCountFailover(true, aActiveBStandby,
aStandbyBActive));
+
+ // (b) Same active URL (no-op transition) → DO NOT COUNT, even if policy
succeeded.
+ assertFalse(
+ "Same active URL on both sides should NOT count as a failover even with
a "
+ + "successful policy transition",
+ HighAvailabilityGroup.shouldCountFailover(true, aActiveBStandby,
aActiveBStandby));
+
+ // (c) Transition INTO no-active state (both STANDBY) → DO NOT COUNT.
+ assertFalse("Transition into a no-active (both STANDBY) state should NOT
count as a failover",
+ HighAvailabilityGroup.shouldCountFailover(true, aActiveBStandby,
bothStandby));
+
+ // (d) NEGATIVE PATH — policy callback failed (transitionSucceeded=false,
simulating
+ // future.get() throwing TimeoutException or ExecutionException) → DO NOT
COUNT, even if
+ // the active URL appears to have moved. This is the regression guard for
the
+ // {@code transitionSucceeded &&} clause; removing it would silently
inflate
+ // HA_FAILOVER_COUNT on failed transitions.
+ assertFalse(
+ "Failed policy transition (transitionSucceeded=false) must NOT count as
a failover even "
+ + "when the candidate new record shows a different active URL",
+ HighAvailabilityGroup.shouldCountFailover(false, aActiveBStandby,
aStandbyBActive));
+
+ // (e) Recovery from no-active back to having an ACTIVE peer with a
successful
+ // transition → COUNT (operationally a real failover-recovery event).
+ assertTrue(
+ "Recovery from no-active back to ACTIVE with a successful policy
transition "
+ + "should count as a failover",
+ HighAvailabilityGroup.shouldCountFailover(true, bothStandby,
aStandbyBActive));
+ }
}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityUtilTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityUtilTest.java
new file mode 100644
index 0000000000..0f28104a2c
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/jdbc/HighAvailabilityUtilTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException;
+import org.apache.hadoop.hbase.client.Row;
+import org.apache.phoenix.exception.MutationBlockedIOException;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link
HighAvailabilityUtil#isMutationBlockedIOExceptionExistsInThrowable}. Walks
+ * the four detection branches (null guard, direct match,
RetriesExhaustedWithDetailsException
+ * causes, recursive {@code getCause()}) since the helper is invoked from
commit-path error handling
+ * where regression of any branch would silently mis-attribute mutation-block
rejections.
+ */
+public class HighAvailabilityUtilTest {
+
+ @Test
+ public void testNullThrowableReturnsFalse() {
+ assertFalse("null Throwable must short-circuit to false",
+
HighAvailabilityUtil.isMutationBlockedIOExceptionExistsInThrowable(null));
+ }
+
+ @Test
+ public void testDirectMutationBlockedIOExceptionMatches() {
+ Throwable e = new MutationBlockedIOException("blocked");
+ assertTrue("direct MutationBlockedIOException must match",
+ HighAvailabilityUtil.isMutationBlockedIOExceptionExistsInThrowable(e));
+ }
+
+ @Test
+ public void testRetriesExhaustedCausesAreScanned() {
+ MutationBlockedIOException blocked = new
MutationBlockedIOException("blocked-in-batch");
+ IOException unrelated = new IOException("unrelated");
+ RetriesExhaustedWithDetailsException rewde =
+ new RetriesExhaustedWithDetailsException(Arrays.<Throwable>
asList(unrelated, blocked),
+ Collections.<Row> emptyList(), Collections.<String> emptyList());
+ assertTrue("RetriesExhaustedWithDetailsException causes must be scanned
for MBIOE",
+
HighAvailabilityUtil.isMutationBlockedIOExceptionExistsInThrowable(rewde));
+ }
+
+ @Test
+ public void testRecursiveGetCauseFindsNestedMutationBlocked() {
+ MutationBlockedIOException root = new
MutationBlockedIOException("root-cause");
+ Throwable mid = new RuntimeException("mid", root);
+ Throwable outer = new RuntimeException("outer", mid);
+ assertTrue("recursive getCause() walk must surface a deeply-nested MBIOE",
+
HighAvailabilityUtil.isMutationBlockedIOExceptionExistsInThrowable(outer));
+ }
+}