diff --git a/pom.xml b/pom.xml index 977d579037..2fff39f111 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-redis - 3.2.11-SNAPSHOT + 3.2.11-GH-2862-SNAPSHOT Spring Data Redis Spring Data module for Redis diff --git a/src/main/java/org/springframework/data/redis/connection/convert/Converters.java b/src/main/java/org/springframework/data/redis/connection/convert/Converters.java index 98ec282faf..ac1f06cebd 100644 --- a/src/main/java/org/springframework/data/redis/connection/convert/Converters.java +++ b/src/main/java/org/springframework/data/redis/connection/convert/Converters.java @@ -20,11 +20,10 @@ import java.time.Duration; import java.util.*; import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; + import org.springframework.core.convert.converter.Converter; import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResult; @@ -63,6 +62,7 @@ * @author daihuabin * @author John Blum * @author Sorokin Evgeniy + * @author Marcin Grzejszczak */ public abstract class Converters { @@ -545,10 +545,15 @@ enum ClusterNodesConverter implements Converter { *
  • {@code %s:%i} (Redis 3)
  • *
  • {@code %s:%i@%i} (Redis 4, with bus port)
  • *
  • {@code %s:%i@%i,%s} (Redis 7, with announced hostname)
  • + * + * The output of the {@code CLUSTER NODES } command is just a space-separated CSV string, where each + * line represents a node in the cluster. The following is an example of output on Redis 7.2.0. + * You can check the latest here. + * + * {@code ... } + * * */ - static final Pattern clusterEndpointPattern = Pattern - .compile("\\[?([0-9a-zA-Z\\-_\\.:]*)\\]?:([0-9]+)(?:@[0-9]+(?:,([^,].*))?)?"); private static final Map flagLookupMap; static { @@ -567,32 +572,88 @@ enum ClusterNodesConverter implements Converter { static final int LINK_STATE_INDEX = 7; static final int SLOTS_INDEX = 8; + /** + * Value object capturing Redis' representation of a cluster node network coordinate. + * + * @author Marcin Grzejszczak + * @author Mark Paluch + */ + record AddressPortHostname(String address, String port, @Nullable String hostname) { + + /** + * Parses Redis {@code CLUSTER NODES} host and port segment into {@link AddressPortHostname}. + */ + static AddressPortHostname parse(String hostAndPortPart) { + + String[] segments = hostAndPortPart.split(","); + int portSeparator = segments[0].lastIndexOf(":"); + Assert.isTrue(portSeparator != -1, "ClusterNode information does not define host and port"); + + String addressPart = getAddressPart(segments[0].substring(0, portSeparator)); + String portPart = getPortPart(segments[0].substring(portSeparator + 1)); + String hostnamePart = segments.length > 1 ? segments[1] : null; + + return new AddressPortHostname(addressPart, portPart, hostnamePart); + } + + private static String getAddressPart(String address) { + return address.startsWith("[") && address.endsWith("]") ? address.substring(1, address.length() - 1) : address; + } + + private static String getPortPart(String segment) { + + if (segment.contains("@")) { + return segment.substring(0, segment.indexOf('@')); + } + + if (segment.contains(":")) { + return segment.substring(0, segment.indexOf(':')); + } + + return segment; + } + + public int portAsInt() { + return Integer.parseInt(port()); + } + + public boolean hasHostname() { + return StringUtils.hasText(hostname()); + } + + public String getRequiredHostname() { + + if (StringUtils.hasText(hostname())) { + return hostname(); + } + + throw new IllegalStateException("Hostname not available"); + } + } + @Override public RedisClusterNode convert(String source) { String[] args = source.split(" "); - Matcher matcher = clusterEndpointPattern.matcher(args[HOST_PORT_INDEX]); + Assert.isTrue(args.length >= MASTER_ID_INDEX + 1, + () -> "Invalid ClusterNode information, insufficient segments: %s".formatted(source)); - Assert.isTrue(matcher.matches(), "ClusterNode information does not define host and port"); - - String addressPart = matcher.group(1); - String portPart = matcher.group(2); - String hostnamePart = matcher.group(3); + AddressPortHostname endpoint = AddressPortHostname.parse(args[HOST_PORT_INDEX]); SlotRange range = parseSlotRange(args); - Set flags = parseFlags(args); + Set flags = parseFlags(args[FLAGS_INDEX]); RedisClusterNodeBuilder nodeBuilder = RedisClusterNode.newRedisClusterNode() - .listeningAt(addressPart, Integer.parseInt(portPart)) // + .listeningAt(endpoint.address(), endpoint.portAsInt()) // .withId(args[ID_INDEX]) // .promotedAs(flags.contains(Flag.MASTER) ? NodeType.MASTER : NodeType.REPLICA) // .serving(range) // .withFlags(flags) // .linkState(parseLinkState(args)); - if (hostnamePart != null) { - nodeBuilder.withName(hostnamePart); + if (endpoint.hasHostname()) { + nodeBuilder.withName(endpoint.getRequiredHostname()); } if (!args[MASTER_ID_INDEX].isEmpty() && !args[MASTER_ID_INDEX].startsWith("-")) { @@ -602,14 +663,12 @@ public RedisClusterNode convert(String source) { return nodeBuilder.build(); } - private Set parseFlags(String[] args) { - - String raw = args[FLAGS_INDEX]; + private Set parseFlags(String source) { Set flags = new LinkedHashSet<>(8, 1); - if (StringUtils.hasText(raw)) { - for (String flag : raw.split(",")) { + if (StringUtils.hasText(source)) { + for (String flag : source.split(",")) { flags.add(flagLookupMap.get(flag)); } } diff --git a/src/test/java/org/springframework/data/redis/connection/convert/ConvertersUnitTests.java b/src/test/java/org/springframework/data/redis/connection/convert/ConvertersUnitTests.java index 74d3f7df8d..6bc5938be3 100644 --- a/src/test/java/org/springframework/data/redis/connection/convert/ConvertersUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/convert/ConvertersUnitTests.java @@ -18,18 +18,18 @@ import static org.assertj.core.api.Assertions.*; import java.util.Iterator; -import java.util.regex.Matcher; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; + import org.springframework.data.redis.connection.RedisClusterNode; import org.springframework.data.redis.connection.RedisClusterNode.Flag; import org.springframework.data.redis.connection.RedisClusterNode.LinkState; import org.springframework.data.redis.connection.RedisNode.NodeType; -import org.springframework.data.redis.connection.convert.Converters.ClusterNodesConverter; +import org.springframework.data.redis.connection.convert.Converters.ClusterNodesConverter.AddressPortHostname; /** * Unit tests for {@link Converters}. @@ -37,6 +37,7 @@ * @author Christoph Strobl * @author Mark Paluch * @author Sorokin Evgeniy + * @author Marcin Grzejszczak */ class ConvertersUnitTests { @@ -72,6 +73,10 @@ class ConvertersUnitTests { private static final String CLUSTER_NODE_WITH_SINGLE_INVALID_IPV6_HOST = "67adfe3df1058896e3cb49d2863e0f70e7e159fa 2a02:6b8:c67:9c:0:6d8b:33da:5a2c: master,nofailover - 0 1692108412315 1 connected 0-5460"; + private static final String CLUSTER_NODE_WITH_SINGLE_IPV4_EMPTY_HOSTNAME = "3765733728631672640db35fd2f04743c03119c6 10.180.0.33:11003@16379, master - 0 1708041426947 2 connected 0-5460"; + + private static final String CLUSTER_NODE_WITH_SINGLE_IPV4_HOSTNAME = "3765733728631672640db35fd2f04743c03119c6 10.180.0.33:11003@16379,hostname1 master - 0 1708041426947 2 connected 0-5460"; + @Test // DATAREDIS-315 void toSetOfRedis30ClusterNodesShouldConvertSingleStringNodesResponseCorrectly() { @@ -248,6 +253,37 @@ void toClusterNodeWithIPv6Hostname() { assertThat(node.getSlotRange().getSlots().size()).isEqualTo(5461); } + @Test // GH-2862 + void toClusterNodeWithIPv4EmptyHostname() { + + RedisClusterNode node = Converters.toClusterNode(CLUSTER_NODE_WITH_SINGLE_IPV4_EMPTY_HOSTNAME); + + assertThat(node.getId()).isEqualTo("3765733728631672640db35fd2f04743c03119c6"); + assertThat(node.getHost()).isEqualTo("10.180.0.33"); + assertThat(node.hasValidHost()).isTrue(); + assertThat(node.getPort()).isEqualTo(11003); + assertThat(node.getType()).isEqualTo(NodeType.MASTER); + assertThat(node.getFlags()).contains(Flag.MASTER); + assertThat(node.getLinkState()).isEqualTo(LinkState.CONNECTED); + assertThat(node.getSlotRange().getSlots().size()).isEqualTo(5461); + } + + @Test // GH-2862 + void toClusterNodeWithIPv4Hostname() { + + RedisClusterNode node = Converters.toClusterNode(CLUSTER_NODE_WITH_SINGLE_IPV4_HOSTNAME); + + assertThat(node.getId()).isEqualTo("3765733728631672640db35fd2f04743c03119c6"); + assertThat(node.getHost()).isEqualTo("10.180.0.33"); + assertThat(node.getName()).isEqualTo("hostname1"); + assertThat(node.hasValidHost()).isTrue(); + assertThat(node.getPort()).isEqualTo(11003); + assertThat(node.getType()).isEqualTo(NodeType.MASTER); + assertThat(node.getFlags()).contains(Flag.MASTER); + assertThat(node.getLinkState()).isEqualTo(LinkState.CONNECTED); + assertThat(node.getSlotRange().getSlots().size()).isEqualTo(5461); + } + @Test // GH-2678 void toClusterNodeWithIPv6HostnameSquareBrackets() { @@ -271,35 +307,64 @@ void toClusterNodeWithInvalidIPv6Hostname() { @ParameterizedTest // GH-2678 @MethodSource("clusterNodesEndpoints") - void shouldAcceptHostPatterns(String endpoint, String expectedAddress, String expectedPort, String expectedHostname) { + void shouldAcceptHostPatterns(String endpoint, AddressPortHostname expected) { - Matcher matcher = ClusterNodesConverter.clusterEndpointPattern.matcher(endpoint); - assertThat(matcher.matches()).isTrue(); + AddressPortHostname addressPortHostname = AddressPortHostname.parse(endpoint); - assertThat(matcher.group(1)).isEqualTo(expectedAddress); - assertThat(matcher.group(2)).isEqualTo(expectedPort); - assertThat(matcher.group(3)).isEqualTo(expectedHostname); + assertThat(addressPortHostname).isEqualTo(expected); } static Stream clusterNodesEndpoints() { - return Stream.of( + Stream regular = Stream.of( // IPv4 with Host, Redis 3 - Arguments.of("1.2.4.4:7379", "1.2.4.4", "7379", null), + Arguments.of("1.2.4.4:7379", new AddressPortHostname("1.2.4.4", "7379", null)), // IPv6 with Host, Redis 3 - Arguments.of("6b8:c67:9c:0:6d8b:33da:5a2c:6380", "6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null), + Arguments.of("6b8:c67:9c:0:6d8b:33da:5a2c:6380", + new AddressPortHostname("6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null)), // Assuming IPv6 in brackets with Host, Redis 3 - Arguments.of("[6b8:c67:9c:0:6d8b:33da:5a2c]:6380", "6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null), + Arguments.of("[6b8:c67:9c:0:6d8b:33da:5a2c]:6380", + new AddressPortHostname("6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null)), // IPv4 with Host and Bus Port, Redis 4 - Arguments.of("127.0.0.1:7382@17382", "127.0.0.1", "7382", null), + Arguments.of("127.0.0.1:7382@17382", new AddressPortHostname("127.0.0.1", "7382", null)), // IPv6 with Host and Bus Port, Redis 4 - Arguments.of("6b8:c67:9c:0:6d8b:33da:5a2c:6380", "6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null), + Arguments.of("6b8:c67:9c:0:6d8b:33da:5a2c:6380", + new AddressPortHostname("6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null)), // Hostname with Port and Bus Port, Redis 7 - Arguments.of("my.host-name.com:7379@17379", "my.host-name.com", "7379", null), + Arguments.of("my.host-name.com:7379@17379", new AddressPortHostname("my.host-name.com", "7379", null)), // With hostname, Redis 7 - Arguments.of("1.2.4.4:7379@17379,my.host-name.com", "1.2.4.4", "7379", "my.host-name.com")); + Arguments.of("1.2.4.4:7379@17379,my.host-name.com", + new AddressPortHostname("1.2.4.4", "7379", "my.host-name.com"))); + + Stream weird = Stream.of( + // Port-only + Arguments.of(":6380", new AddressPortHostname("", "6380", null)), + + // Port-only with bus-port + Arguments.of(":6380@6381", new AddressPortHostname("", "6380", null)), + // IP with trailing comma + Arguments.of("127.0.0.1:6380,", new AddressPortHostname("127.0.0.1", "6380", null)), + // IPv6 with bus-port + Arguments.of("2a02:6b8:c67:9c:0:6d8b:33da:5a2c:6380@6381", + new AddressPortHostname("2a02:6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null)), + // IPv6 with bus-port and hostname + Arguments.of("2a02:6b8:c67:9c:0:6d8b:33da:5a2c:6380@6381,hostname1", + new AddressPortHostname("2a02:6b8:c67:9c:0:6d8b:33da:5a2c", "6380", "hostname1")), + // Port-only with hostname + Arguments.of(":6380,hostname1", new AddressPortHostname("", "6380", "hostname1")), + + // Port-only with bus-port + Arguments.of(":6380@6381,hostname1", new AddressPortHostname("", "6380", "hostname1")), + // IPv6 in brackets with bus-port + Arguments.of("[2a02:6b8:c67:9c:0:6d8b:33da:5a2c]:6380@6381", + new AddressPortHostname("2a02:6b8:c67:9c:0:6d8b:33da:5a2c", "6380", null)), + // IPv6 in brackets with bus-port and hostname + Arguments.of("[2a02:6b8:c67:9c:0:6d8b:33da:5a2c]:6380@6381,hostname1", + new AddressPortHostname("2a02:6b8:c67:9c:0:6d8b:33da:5a2c", "6380", "hostname1"))); + + return Stream.concat(regular, weird); } }