Skip to content

Commit 6b24780

Browse files
lokioreclaude
andcommitted
PHOENIX-7973 :- Address review: reconcile diagnostics, interrupt handling, and endpoint/refresh test wiring
Follow-up to the reviewer's minor items on the two-endpoint CRR path (no correctness/concurrency changes to the reconcile decision tree itself): - getClusterRoleRecordFromEndpoint restructured so reconciliation runs OUTSIDE both per-endpoint fetch try/catch blocks. A bug in reconcile now surfaces as itself rather than being caught by a fetch catch, misattributed as an endpoint failure, and silently degraded to a single-endpoint record. - The cluster-1 failure path now logs the cluster-1 exception (WARN) before falling back to cluster 2, so a cluster-1 failure (including an unchecked bug such as an NPE) no longer vanishes when cluster 2 succeeds. The interrupt status the fetch cleared is restored in a finally after the fallback fetch, so a stale flag cannot pre-empt the blocking work while callers still observe cancellation. - On both cluster-1 fallback paths, if cluster 2 also fails the cluster-1 failure is retained via addSuppressed instead of being dropped: the CRR-Not- Found path rethrows the original Not-Found (cluster-2 suppressed) so downstream fallback still triggers, and the non-Not-Found path rethrows cluster 2's exception with cluster 1's attached as suppressed, so the single propagating exception carries both root causes. - Corrected the reconcile carve-out comment (and its mirror in the test): a newer-UNKNOWN-with-no-active record stays masked behind the usable record and recovery comes from the next scheduled refresh (the poller runs only if the usable record is itself non-active). Tests added in HighAvailabilityGroupTest: - testIsCausedByInterrupt: both interrupt marker types, wrapped, non-interrupt, null, the depth-16 bound (within and beyond), and a cyclic cause chain. - testRefreshDoesNotRollBackToOlderRecord: wires the refresh no-rollback branch end to end (keeps the applied record, stays READY, no failover count, returns true). - testGetClusterRoleRecordFromEndpointWiring: pins url1->cluster1 / url2->cluster2 and that the applied record is threaded as current on the equal-version defer. - Equal-version divergence with the applied record at a lower version than the endpoints now covered (the non-first-load fall-through). Small testability seams added: package-private @VisibleForTesting on getClusterRoleRecordFromEndpoint, isCausedByInterrupt, a fetchClusterRoleRecord per-endpoint seam, and getStateForTesting. Generated-by: Claude Code (Opus 4.8) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fb7870 commit 6b24780

2 files changed

Lines changed: 232 additions & 44 deletions

File tree

phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/HighAvailabilityGroup.java

Lines changed: 92 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,11 @@ public ClusterRoleRecord getRoleRecord() {
773773
return roleRecord;
774774
}
775775

776+
@VisibleForTesting
777+
State getStateForTesting() {
778+
return state;
779+
}
780+
776781
/**
777782
* Package private close method.
778783
* <p>
@@ -993,77 +998,121 @@ private static void throwMalFormedConnectionUrlException(String message) throws
993998
* reachable — that single endpoint's un-reconciled record
994999
* @throws SQLException if there is an error getting the ClusterRoleRecord
9951000
*/
996-
private ClusterRoleRecord getClusterRoleRecordFromEndpoint() throws SQLException {
1001+
@VisibleForTesting
1002+
ClusterRoleRecord getClusterRoleRecordFromEndpoint() throws SQLException {
9971003
long pollerInterval =
9981004
Long.parseLong(properties.getProperty(PHOENIX_HA_CRR_POLLER_INTERVAL_MS_KEY, config
9991005
.get(PHOENIX_HA_CRR_POLLER_INTERVAL_MS_KEY, PHOENIX_HA_CRR_POLLER_INTERVAL_MS_DEFAULT)));
10001006

1001-
ClusterRoleRecord resolvedRecord;
1007+
ClusterRoleRecord resolvedRecord = null;
1008+
ClusterRoleRecord roleRecord1 = null;
10021009
try {
10031010
// Read cluster 1's CRR (read-only; no poller side effect).
1004-
ClusterRoleRecord roleRecord = GetClusterRoleRecordUtil.getClusterRoleRecord(info.getUrl1(),
1005-
info.getName(), true, properties);
1006-
// Read cluster 2's CRR and reconcile; if cluster 2 is unreachable, keep cluster 1's record.
1011+
roleRecord1 = fetchClusterRoleRecord(info.getUrl1());
1012+
} catch (Exception e) {
1013+
// Cluster 1 failed: fall back to cluster 2 (single endpoint, no reconciliation). Log first,
1014+
// otherwise a cluster-1 failure (including an unchecked bug such as an NPE) vanishes whenever
1015+
// cluster 2 succeeds.
1016+
LOG.warn("Cluster 1 endpoint {} for HA group {} threw an exception; attempting cluster 2 "
1017+
+ "endpoint {}", info.getUrl1(), info.getName(), info.getUrl2(), e);
1018+
// Restore the interrupt status the cluster-1 fetch cleared in a finally, AFTER the fallback
1019+
// fetch on every exit path (normal or thrown): a stale flag set before the fetch would
1020+
// pre-empt the blocking work we depend on, but callers must still observe cancellation.
1021+
boolean cluster1Interrupted = isCausedByInterrupt(e);
10071022
try {
1008-
ClusterRoleRecord roleRecordFromPR = GetClusterRoleRecordUtil
1009-
.getClusterRoleRecord(info.getUrl2(), info.getName(), true, properties);
1010-
// Pass the currently applied record (this.roleRecord; null on first load) so an
1011-
// equal-version divergence between the endpoints defers to it rather than flapping.
1012-
resolvedRecord = reconcileClusterRoleRecords(roleRecord, roleRecordFromPR, this.roleRecord);
1013-
if (!roleRecord.equals(roleRecordFromPR)) {
1014-
LOG.info(
1015-
"Reconciled divergent CRRs for HA group {}: cluster1={} (V{}), cluster2={} (V{}); "
1016-
+ "chose {} (V{})",
1017-
info.getName(), roleRecord, roleRecord.getVersion(), roleRecordFromPR,
1018-
roleRecordFromPR.getVersion(), resolvedRecord, resolvedRecord.getVersion());
1023+
// On CRR-Not-Found, if cluster 2 also fails rethrow the original Not-Found (with the
1024+
// cluster-2 failure suppressed) so downstream fallback can trigger.
1025+
if (
1026+
e instanceof SQLException && ((SQLException) e).getErrorCode()
1027+
== SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode()
1028+
) {
1029+
try {
1030+
resolvedRecord = fetchClusterRoleRecord(info.getUrl2());
1031+
} catch (Exception ignoredEx) {
1032+
((SQLException) e).addSuppressed(ignoredEx);
1033+
throw (SQLException) e;
1034+
}
1035+
} else {
1036+
// If caught exception is not CRR-Not-Found, try the cluster 2 endpoint. If cluster 2 also
1037+
// fails, attach cluster 1's failure as suppressed so the single propagating exception
1038+
// carries both root causes (parity with the Not-Found path above).
1039+
try {
1040+
resolvedRecord = fetchClusterRoleRecord(info.getUrl2());
1041+
} catch (Exception cluster2Ex) {
1042+
cluster2Ex.addSuppressed(e);
1043+
throw cluster2Ex;
1044+
}
1045+
}
1046+
} finally {
1047+
if (cluster1Interrupted) {
1048+
Thread.currentThread().interrupt();
10191049
}
1050+
}
1051+
}
1052+
1053+
if (roleRecord1 != null) {
1054+
// Cluster 1 reachable. Read cluster 2's CRR; if cluster 2 is unreachable, degrade to
1055+
// cluster 1's record.
1056+
ClusterRoleRecord roleRecord2 = null;
1057+
try {
1058+
roleRecord2 = fetchClusterRoleRecord(info.getUrl2());
10201059
} catch (Exception e) {
10211060
// Any cluster 2 fetch failure degrades to cluster 1's record. Catch broadly: an unchecked
1022-
// exception from the pre-RPC connect path is still a reachability failure, and letting it
1023-
// reach the outer catch would discard the cluster 1 record we already hold. Restore the
1024-
// interrupt status if the failure wrapped one, so callers can observe cancellation.
1061+
// exception from the pre-RPC connect path is still a reachability failure. No further
1062+
// blocking work follows here, so restore the interrupt status immediately if the failure
1063+
// wrapped one, so callers can observe cancellation.
10251064
if (isCausedByInterrupt(e)) {
10261065
Thread.currentThread().interrupt();
10271066
}
10281067
LOG.warn(
10291068
"Fetched CRR {} from cluster {} but cluster {} endpoint threw an exception; "
10301069
+ "returning cluster {} CRR without peer reconciliation",
1031-
roleRecord.toPrettyString(), info.getUrl1(), info.getUrl2(), info.getUrl1(), e);
1032-
resolvedRecord = roleRecord;
1070+
roleRecord1.toPrettyString(), info.getUrl1(), info.getUrl2(), info.getUrl1(), e);
10331071
}
1034-
} catch (Exception e) {
1035-
// Cluster 1 failed: fall back to cluster 2. On CRR-Not-Found, if cluster 2 also fails
1036-
// rethrow the original Not-Found so downstream fallback can trigger.
1037-
if (
1038-
e instanceof SQLException && ((SQLException) e).getErrorCode()
1039-
== SQLExceptionCode.CLUSTER_ROLE_RECORD_NOT_FOUND.getErrorCode()
1040-
) {
1041-
try {
1042-
resolvedRecord = GetClusterRoleRecordUtil.getClusterRoleRecord(info.getUrl2(),
1043-
info.getName(), true, properties);
1044-
} catch (Exception ignoredEx) {
1045-
throw (SQLException) e;
1046-
}
1072+
if (roleRecord2 == null) {
1073+
resolvedRecord = roleRecord1;
10471074
} else {
1048-
// If caught exception is not CRR not found, then just try cluster 2 endpoint.
1049-
resolvedRecord = GetClusterRoleRecordUtil.getClusterRoleRecord(info.getUrl2(),
1050-
info.getName(), true, properties);
1075+
// Both endpoints reachable. Reconcile OUTSIDE both fetch try/catch blocks: it is pure
1076+
// computation, so a bug here surfaces as itself rather than being caught by a fetch catch,
1077+
// misreported as an endpoint failure, and silently degraded to a single-endpoint record.
1078+
// Pass the currently applied record (this.roleRecord; null on first load) so an
1079+
// equal-version divergence defers to it rather than flapping.
1080+
resolvedRecord = reconcileClusterRoleRecords(roleRecord1, roleRecord2, this.roleRecord);
1081+
if (!roleRecord1.equals(roleRecord2)) {
1082+
LOG.info(
1083+
"Reconciled divergent CRRs for HA group {}: cluster1={} (V{}), cluster2={} (V{}); "
1084+
+ "chose {} (V{})",
1085+
info.getName(), roleRecord1, roleRecord1.getVersion(), roleRecord2,
1086+
roleRecord2.getVersion(), resolvedRecord, resolvedRecord.getVersion());
1087+
}
10511088
}
10521089
}
10531090

1054-
// Schedule the non-active CRR poller at most once, gated on the resolved record. maybeSchedule
1055-
// is a no-op when the record has an active role, so this is safe to call unconditionally.
1091+
// resolvedRecord is non-null here: either the cluster-1 fallback set it, or the cluster-1
1092+
// reachable branch did. Schedule the non-active CRR poller at most once, gated on the resolved
1093+
// record. maybeSchedulePoller is a no-op when the record has an active role, so this is safe to
1094+
// call unconditionally.
10561095
GetClusterRoleRecordUtil.maybeSchedulePoller(info.getUrl1(), info.getUrl2(), info.getName(),
10571096
this, resolvedRecord, pollerInterval, properties);
10581097
return resolvedRecord;
10591098
}
10601099

1100+
/**
1101+
* Reads a single endpoint's CRR (a pure read; no poller side effect). Extracted as a seam so unit
1102+
* tests can stub per-endpoint fetches without a mini-cluster.
1103+
*/
1104+
@VisibleForTesting
1105+
ClusterRoleRecord fetchClusterRoleRecord(String url) throws SQLException {
1106+
return GetClusterRoleRecordUtil.getClusterRoleRecord(url, info.getName(), true, properties);
1107+
}
1108+
10611109
/**
10621110
* True if {@code t}'s cause chain (bounded against cyclic causes) carries an interruption marker.
10631111
* A blocking endpoint RPC surfaces the interruption wrapped inside the thrown exception, and the
10641112
* JVM has already cleared the thread's interrupt flag, so the caller must restore it explicitly.
10651113
*/
1066-
private static boolean isCausedByInterrupt(Throwable t) {
1114+
@VisibleForTesting
1115+
static boolean isCausedByInterrupt(Throwable t) {
10671116
for (int depth = 0; t != null && depth < 16; t = t.getCause(), depth++) {
10681117
if (t instanceof InterruptedException || t instanceof InterruptedIOException) {
10691118
return true;
@@ -1311,8 +1360,9 @@ static ClusterRoleRecord reconcileClusterRoleRecords(ClusterRoleRecord recordFro
13111360
// still names an active cluster (one role resolved ACTIVE while its peer is mid-transition and
13121361
// momentarily UNKNOWN). There the newer record reflects a real state advance, and masking it
13131362
// behind a stale fully-known record would keep routing to a since-demoted cluster, so the newer
1314-
// record wins. A newer UNKNOWN record with NO active role stays masked: it cannot route a
1315-
// connection, and the non-active poller picks up the true state on its next tick.
1363+
// record wins. A newer UNKNOWN record with NO active role stays masked behind the usable
1364+
// record: it cannot route a connection, so recovery comes from the next scheduled refresh (and
1365+
// the poller only if the usable record is itself non-active, which schedules it after return).
13161366
if (recordFromCluster1.hasUnknownRole() != recordFromCluster2.hasUnknownRole()) {
13171367
ClusterRoleRecord unknownRecord =
13181368
recordFromCluster1.hasUnknownRole() ? recordFromCluster1 : recordFromCluster2;

0 commit comments

Comments
 (0)