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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7693b1979f8 HDDS-15895. Make ServerNotLeaderException suggested leader 
flow IPv6-safe (#10807)
7693b1979f8 is described below

commit 7693b1979f8165ebebcdda33a92d30e061f8a63e
Author: Chi-Hsuan Huang <[email protected]>
AuthorDate: Thu Aug 13 07:07:52 2026 +0800

    HDDS-15895. Make ServerNotLeaderException suggested leader flow IPv6-safe 
(#10807)
---
 .../hdds/ratis/ServerNotLeaderException.java       |   5 +-
 ...TestServerNotLeaderExceptionMessageParsing.java |  22 +++
 .../scm/proxy/SCMFailoverProxyProviderBase.java    |  95 ++++++++++--
 .../TestSCMFailoverProxyProviderRefreshWired.java  | 167 ++++++++++++++++++++-
 4 files changed, 277 insertions(+), 12 deletions(-)

diff --git 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/ratis/ServerNotLeaderException.java
 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/ratis/ServerNotLeaderException.java
index ecf5333b80b..685b42aa852 100644
--- 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/ratis/ServerNotLeaderException.java
+++ 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/ratis/ServerNotLeaderException.java
@@ -32,7 +32,7 @@ public class ServerNotLeaderException extends IOException {
   private static final Pattern CURRENT_PEER_ID_PATTERN =
       Pattern.compile(".* Server:(.*?) is not the leader[.]+.*", 
Pattern.DOTALL);
   private static final Pattern SUGGESTED_LEADER_PATTERN =
-      Pattern.compile(".*Suggested leader is Server:([^:]*)(:[0-9]+).*",
+      Pattern.compile(".*Suggested leader is 
Server:(\\[[^\\]]+\\]|[^:]*)(:[0-9]+).*",
           Pattern.DOTALL);
 
   public ServerNotLeaderException(RaftPeerId currentPeerId, String hostname,
@@ -99,7 +99,8 @@ public static ServerNotLeaderException 
convertToNotLeaderException(
         null;
     ServerNotLeaderException serverNotLeaderException;
     if (suggestedLeader != null) {
-      String suggestedLeaderHostPort = suggestedLeader + ":" + port;
+      String suggestedLeaderHostPort =
+          HddsUtils.getHostPortString(suggestedLeader, Integer.parseInt(port));
       serverNotLeaderException =
           new ServerNotLeaderException(currentPeer, suggestedLeaderHostPort, 
hostname, roleType);
     } else {
diff --git 
a/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/ratis/TestServerNotLeaderExceptionMessageParsing.java
 
b/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/ratis/TestServerNotLeaderExceptionMessageParsing.java
index f19fa67a9c5..07203529261 100644
--- 
a/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/ratis/TestServerNotLeaderExceptionMessageParsing.java
+++ 
b/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/ratis/TestServerNotLeaderExceptionMessageParsing.java
@@ -19,7 +19,12 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import org.apache.ratis.protocol.RaftPeer;
+import org.apache.ratis.protocol.RaftPeerId;
+import org.apache.ratis.protocol.exceptions.NotLeaderException;
 import org.junit.jupiter.api.Test;
 
 /** Class to test {@link ServerNotLeaderException} parsing. **/
@@ -75,4 +80,21 @@ public void testServerNotLeaderException() {
     assertNull(snle.getSuggestedLeader());
   }
 
+  @Test
+  public void testIpv6SuggestedLeader() {
+    RaftPeer suggestedLeader = RaftPeer.newBuilder()
+        .setId("scm2")
+        .setAddress("[2001:db8::1]:9894")
+        .build();
+    NotLeaderException notLeaderException = mock(NotLeaderException.class);
+    when(notLeaderException.getSuggestedLeader()).thenReturn(suggestedLeader);
+
+    ServerNotLeaderException converted = 
ServerNotLeaderException.convertToNotLeaderException(
+        notLeaderException, RaftPeerId.valueOf("scm1"), "9863", "2001:db8::2", 
"SCM");
+    assertEquals("[2001:db8::1]:9863", converted.getSuggestedLeader());
+
+    ServerNotLeaderException parsed = new 
ServerNotLeaderException(converted.getMessage());
+    assertEquals("[2001:db8::1]:9863", parsed.getSuggestedLeader());
+  }
+
 }
diff --git 
a/hadoop-hdds/framework/src/main/java/org/apache/hadoop/hdds/scm/proxy/SCMFailoverProxyProviderBase.java
 
b/hadoop-hdds/framework/src/main/java/org/apache/hadoop/hdds/scm/proxy/SCMFailoverProxyProviderBase.java
index 4daf3144261..05bcbf6bffa 100644
--- 
a/hadoop-hdds/framework/src/main/java/org/apache/hadoop/hdds/scm/proxy/SCMFailoverProxyProviderBase.java
+++ 
b/hadoop-hdds/framework/src/main/java/org/apache/hadoop/hdds/scm/proxy/SCMFailoverProxyProviderBase.java
@@ -18,6 +18,7 @@
 package org.apache.hadoop.hdds.scm.proxy;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.net.InetAddresses;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -29,8 +30,10 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.OptionalInt;
 import java.util.stream.Collectors;
 import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hdds.HddsUtils;
 import org.apache.hadoop.hdds.conf.ConfigurationException;
 import org.apache.hadoop.hdds.conf.ConfigurationSource;
 import org.apache.hadoop.hdds.ratis.ServerNotLeaderException;
@@ -249,24 +252,98 @@ public synchronized void 
performFailoverToAssignedLeader(String newLeader,
                                                            Exception e) {
     ServerNotLeaderException snle =
         (ServerNotLeaderException) SCMHAUtils.getServerNotLeaderException(e);
-    if (snle != null && snle.getSuggestedLeader() != null) {
-      Optional<SCMProxyInfo> matchedProxyInfo =
-          scmProxyInfoMap.values().stream().filter(
-              proxyInfo -> NetUtils.getHostPortString(proxyInfo.getAddress())
-                  .equals(snle.getSuggestedLeader())).findFirst();
-      if (matchedProxyInfo.isPresent()) {
-        newLeader = matchedProxyInfo.get().getNodeId();
+    String suggestedLeader = snle != null ? snle.getSuggestedLeader() : null;
+    if (suggestedLeader != null) {
+      Optional<String> matchedNodeId = 
findSuggestedLeaderNodeId(suggestedLeader);
+      if (matchedNodeId.isPresent()) {
+        newLeader = matchedNodeId.get();
         getLogger().debug("Performing failover to suggested leader {}, nodeId 
{}",
-            snle.getSuggestedLeader(), newLeader);
+            suggestedLeader, newLeader);
       } else {
         getLogger().debug("Suggested leader {} does not match with any of the 
" +
-                "proxyInfo address {}", snle.getSuggestedLeader(),
+                "proxyInfo address {}", suggestedLeader,
             Arrays.toString(scmProxyInfoMap.values().toArray()));
       }
     }
     assignLeaderToNode(newLeader);
   }
 
+  /**
+   * Find the SCM nodeId whose address matches the suggested-leader authority. 
Callers hold
+   * the provider monitor, so this must never resolve a name.
+   */
+  private Optional<String> findSuggestedLeaderNodeId(String suggestedLeader) {
+    final Optional<String> host;
+    final OptionalInt port;
+    try {
+      host = HddsUtils.getHostName(suggestedLeader);
+      port = HddsUtils.getHostPort(suggestedLeader);
+    } catch (IllegalArgumentException ex) {
+      getLogger().warn("Ignoring unparseable suggested leader {}", 
suggestedLeader, ex);
+      return Optional.empty();
+    }
+    if (!host.isPresent() || !port.isPresent()) {
+      return Optional.empty();
+    }
+    return scmProxyInfoMap.values().stream()
+        .filter(proxyInfo -> matchesAuthority(proxyInfo.getAddress(), 
host.get(), port.getAsInt()))
+        .findFirst()
+        .map(SCMProxyInfo::getNodeId);
+  }
+
+  private static boolean matchesAuthority(InetSocketAddress address, String 
host, int port) {
+    if (address.getPort() != port) {
+      return false;
+    }
+    // Both getHostString() and getAddress() read what was resolved when the 
proxy info was
+    // built, so neither triggers a lookup here.
+    String addressHost = address.getHostString();
+    if (sameHost(addressHost, host)) {
+      return true;
+    }
+    InetAddress resolved = address.getAddress();
+    return resolved != null && sameIpLiteral(resolved.getHostAddress(), host);
+  }
+
+  /**
+   * Whether two authority hosts denote the same SCM. Names are matched 
case-insensitively as DNS
+   * requires, while IP literals go through {@link #sameIpLiteral} so that an 
IPv6 zone keeps its
+   * case: eth0 and ETH0 are different interfaces.
+   */
+  private static boolean sameHost(String addressHost, String host) {
+    if (isIpLiteral(addressHost) || isIpLiteral(host)) {
+      return sameIpLiteral(addressHost, host);
+    }
+    return addressHost.equalsIgnoreCase(host);
+  }
+
+  /**
+   * Whether two IP literals denote the same address, allowing for different 
spellings of one
+   * IPv6 address. Scope is compared as text, unlike {@link 
InetAddress#equals} which drops it,
+   * so fe80::1%1 and fe80::1%2 differ, and so do %2 and %eth0 for the same 
interface.
+   */
+  private static boolean sameIpLiteral(String host, String otherHost) {
+    String ip = stripScope(host);
+    String otherIp = stripScope(otherHost);
+    return scopeOf(host).equals(scopeOf(otherHost))
+        && InetAddresses.isInetAddress(ip) && 
InetAddresses.isInetAddress(otherIp)
+        && 
InetAddresses.forString(ip).equals(InetAddresses.forString(otherIp));
+  }
+
+  private static boolean isIpLiteral(String host) {
+    return InetAddresses.isInetAddress(stripScope(host));
+  }
+
+  private static String stripScope(String host) {
+    int mark = host.indexOf('%');
+    return mark < 0 ? host : host.substring(0, mark);
+  }
+
+  private static String scopeOf(String host) {
+    int mark = host.indexOf('%');
+    return mark < 0 ? "" : host.substring(mark + 1);
+  }
+
   @Override
   public Class<T> getInterface() {
     return protocolClass;
diff --git 
a/hadoop-hdds/framework/src/test/java/org/apache/hadoop/hdds/scm/proxy/TestSCMFailoverProxyProviderRefreshWired.java
 
b/hadoop-hdds/framework/src/test/java/org/apache/hadoop/hdds/scm/proxy/TestSCMFailoverProxyProviderRefreshWired.java
index e082ac0f7db..83a08a7d8df 100644
--- 
a/hadoop-hdds/framework/src/test/java/org/apache/hadoop/hdds/scm/proxy/TestSCMFailoverProxyProviderRefreshWired.java
+++ 
b/hadoop-hdds/framework/src/test/java/org/apache/hadoop/hdds/scm/proxy/TestSCMFailoverProxyProviderRefreshWired.java
@@ -21,16 +21,22 @@
 import static org.apache.hadoop.hdds.scm.ScmConfigKeys.OZONE_SCM_NODES_KEY;
 import static 
org.apache.hadoop.hdds.scm.ScmConfigKeys.OZONE_SCM_SERVICE_IDS_KEY;
 import static 
org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_CLIENT_FAILOVER_RESOLVE_NEEDED_KEY;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 
 import java.io.IOException;
 import java.net.ConnectException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
 import java.net.SocketTimeoutException;
+import java.util.ArrayList;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.ratis.ServerNotLeaderException;
 import org.apache.hadoop.io.retry.RetryPolicy;
 import org.apache.hadoop.ozone.ha.ConfUtils;
+import org.apache.ozone.test.GenericTestUtils.LogCapturer;
+import org.apache.ratis.protocol.RaftPeerId;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -41,13 +47,15 @@
  * Complements {@code TestConnectionFailureUtils} (helper-in-isolation)
  * and {@code TestSCMFailoverProxyProviderRefresh} (per-instance refresh)
  * by exercising the actual retry policy whose return value drives the
- * RetryInvocationHandler in production.
+ * RetryInvocationHandler in production. It also verifies wired
+ * suggested-leader failover target selection.
  */
 public class TestSCMFailoverProxyProviderRefreshWired {
 
   private static final String SCM_SERVICE_ID = "scmservice";
   private static final String SCM_NODE_1 = "scm1";
   private static final String SCM_NODE_2 = "scm2";
+  private static final String SCM_NODE_3 = "scm3";
 
   private OzoneConfiguration conf;
 
@@ -121,6 +129,163 @@ public void 
testApplicationLevelErrorDoesNotTriggerRefresh() throws Exception {
         "ServerNotLeaderException is application-level; refresh must NOT 
fire");
   }
 
+  @Test
+  public void testFailoverToIpv6SuggestedLeader() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    SCMProxyInfo leaderProxy = proxyInfoOf(provider, SCM_NODE_3);
+    int port = leaderProxy.getAddress().getPort();
+    provider.replaceProxyInfoForTest(SCM_NODE_3,
+        new SCMProxyInfo(leaderProxy.getServiceId(), SCM_NODE_3,
+            new InetSocketAddress("2001:db8::1", port)));
+
+    // The cached address expands to 2001:db8:0:0:0:0:0:1, so the compressed 
hint only
+    // matches through the canonical comparison.
+    failoverWith(provider, notLeader("[2001:db8::1]:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "suggested leader must override the next round-robin SCM");
+  }
+
+  @Test
+  public void testFailoverToIpv4SuggestedLeader() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    SCMProxyInfo leaderProxy = proxyInfoOf(provider, SCM_NODE_3);
+    // Give scm3 a distinct IPv4 address so its authority is unambiguous among 
the
+    // localhost nodes.
+    int port = leaderProxy.getAddress().getPort();
+    provider.replaceProxyInfoForTest(SCM_NODE_3,
+        new SCMProxyInfo(leaderProxy.getServiceId(), SCM_NODE_3,
+            new InetSocketAddress("127.0.0.2", port)));
+
+    failoverWith(provider, notLeader("127.0.0.2:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "an IPv4 suggested leader must keep matching");
+  }
+
+  @Test
+  public void testFailoverToSuggestedLeaderMatchesWithoutResolution() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    SCMProxyInfo leaderProxy = proxyInfoOf(provider, SCM_NODE_3);
+    // An address that was never resolved: matching it can only be done on the 
host text,
+    // and getAddress() is null.
+    int port = leaderProxy.getAddress().getPort();
+    provider.replaceProxyInfoForTest(SCM_NODE_3,
+        new SCMProxyInfo(leaderProxy.getServiceId(), SCM_NODE_3,
+            InetSocketAddress.createUnresolved("scm3.example.com", port)));
+
+    failoverWith(provider, notLeader("scm3.example.com:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "suggested leader must match by authority without DNS resolution");
+  }
+
+  @Test
+  public void testSuggestedLeaderDistinguishesIpv6ScopeIds() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    // Every node gets the same IPv6 address and port; only the scope tells 
scm3 apart, so a
+    // scope-blind comparison matches one of the other two whatever the 
iteration order.
+    int port = proxyInfoOf(provider, SCM_NODE_3).getAddress().getPort();
+    for (SCMProxyInfo proxyInfo : new 
ArrayList<>(provider.getSCMProxyInfoList())) {
+      String host = SCM_NODE_3.equals(proxyInfo.getNodeId()) ? "fe80::1%2" : 
"fe80::1%1";
+      provider.replaceProxyInfoForTest(proxyInfo.getNodeId(),
+          new SCMProxyInfo(proxyInfo.getServiceId(), proxyInfo.getNodeId(),
+              new InetSocketAddress(host, port)));
+    }
+
+    failoverWith(provider, notLeader("[fe80::1%2]:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "a differing IPv6 scope must not be treated as the same SCM");
+  }
+
+  @Test
+  public void testSuggestedLeaderDistinguishesIpv6ZoneNameCase() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    // As above, but the scope is an interface name. Interface names are 
case-sensitive, so a
+    // case-insensitive host comparison matches one of the ETH0 nodes instead 
of scm3.
+    int port = proxyInfoOf(provider, SCM_NODE_3).getAddress().getPort();
+    for (SCMProxyInfo proxyInfo : new 
ArrayList<>(provider.getSCMProxyInfoList())) {
+      String host = SCM_NODE_3.equals(proxyInfo.getNodeId()) ? "fe80::1%eth0" 
: "fe80::1%ETH0";
+      provider.replaceProxyInfoForTest(proxyInfo.getNodeId(),
+          new SCMProxyInfo(proxyInfo.getServiceId(), proxyInfo.getNodeId(),
+              InetSocketAddress.createUnresolved(host, port)));
+    }
+
+    failoverWith(provider, notLeader("[fe80::1%eth0]:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "a differing IPv6 zone name must not be treated as the same SCM");
+  }
+
+  @Test
+  public void testSuggestedLeaderMatchesResolvedAddressOfHostname() throws 
Exception {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    SCMProxyInfo leaderProxy = proxyInfoOf(provider, SCM_NODE_3);
+    int port = leaderProxy.getAddress().getPort();
+    // scm3 is configured by hostname but already resolved to an IPv6 address, 
which is what
+    // Ratis reports in the hint. getByAddress attaches the hostname without 
any lookup.
+    InetAddress resolved = InetAddress.getByAddress("scm3.example.com",
+        InetAddress.getByName("2001:db8::1").getAddress());
+    provider.replaceProxyInfoForTest(SCM_NODE_3,
+        new SCMProxyInfo(leaderProxy.getServiceId(), SCM_NODE_3,
+            new InetSocketAddress(resolved, port)));
+
+    failoverWith(provider, notLeader("[2001:db8::1]:" + port));
+
+    assertEquals(SCM_NODE_3, provider.getCurrentProxySCMNodeId(),
+        "a hint carrying the resolved IP must match a hostname-configured 
SCM");
+  }
+
+  @Test
+  public void testUnparseableSuggestedLeaderKeepsRoundRobin() {
+    SCMBlockLocationFailoverProxyProvider provider = newThreeNodeProvider();
+    int port = proxyInfoOf(provider, SCM_NODE_3).getAddress().getPort();
+    // The message parser's non-bracketed alternative accepts a malformed 
authority such as
+    // [a]b:9863, which no longer parses as host and port on the client side.
+    String malformed = "[a]b:" + port;
+    ServerNotLeaderException parsed =
+        new ServerNotLeaderException(notLeader(malformed).getMessage());
+    assertEquals(malformed, parsed.getSuggestedLeader());
+
+    LogCapturer log = 
LogCapturer.captureLogs(SCMBlockLocationFailoverProxyProvider.class);
+    try {
+      failoverWith(provider, parsed);
+    } finally {
+      log.stopCapturing();
+    }
+
+    assertThat(log.getOutput()).contains("Ignoring unparseable suggested 
leader " + malformed);
+    assertEquals(SCM_NODE_2, provider.getCurrentProxySCMNodeId(),
+        "an unparseable suggested leader must fall back to round-robin 
failover");
+  }
+
+  private SCMBlockLocationFailoverProxyProvider newThreeNodeProvider() {
+    conf.set(OZONE_SCM_NODES_KEY + "." + SCM_SERVICE_ID,
+        SCM_NODE_1 + "," + SCM_NODE_2 + "," + SCM_NODE_3);
+    conf.set(ConfUtils.addKeySuffixes(OZONE_SCM_ADDRESS_KEY,
+        SCM_SERVICE_ID, SCM_NODE_3), "localhost");
+    return new SCMBlockLocationFailoverProxyProvider(conf);
+  }
+
+  private static SCMProxyInfo proxyInfoOf(
+      SCMBlockLocationFailoverProxyProvider provider, String nodeId) {
+    return provider.getSCMProxyInfoList().stream()
+        .filter(proxyInfo -> nodeId.equals(proxyInfo.getNodeId()))
+        .findFirst().get();
+  }
+
+  private static ServerNotLeaderException notLeader(String suggestedLeader) {
+    return new ServerNotLeaderException(RaftPeerId.valueOf(SCM_NODE_1),
+        suggestedLeader, "localhost", "SCM");
+  }
+
+  private static void failoverWith(SCMBlockLocationFailoverProxyProvider 
provider,
+      ServerNotLeaderException e) {
+    provider.performFailoverToAssignedLeader(null, e);
+    provider.performFailover(null);
+  }
+
   @Test
   public void testFlagDisabledSuppressesRefresh() throws Exception {
     conf.setBoolean(OZONE_CLIENT_FAILOVER_RESOLVE_NEEDED_KEY, false);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to