Merge pull request #42588 from nosan

* pr/42588:
  Polish "Add spring.data.redis.lettuce.read-from property"
  Add spring.data.redis.lettuce.read-from property

Closes gh-42588
This commit is contained in:
Stéphane Nicoll 2025-01-07 16:50:56 +01:00
commit db8e1640a1
4 changed files with 174 additions and 1 deletions

View File

@ -19,6 +19,7 @@ package org.springframework.boot.autoconfigure.data.redis;
import java.time.Duration;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.RedisClient;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.TimeoutOptions;
@ -163,12 +164,35 @@ class LettuceConnectionConfiguration extends RedisConnectionConfiguration {
if (lettuce.getShutdownTimeout() != null && !lettuce.getShutdownTimeout().isZero()) {
builder.shutdownTimeout(getProperties().getLettuce().getShutdownTimeout());
}
String readFrom = lettuce.getReadFrom();
if (readFrom != null) {
builder.readFrom(getReadFrom(readFrom));
}
}
if (StringUtils.hasText(getProperties().getClientName())) {
builder.clientName(getProperties().getClientName());
}
}
private ReadFrom getReadFrom(String readFrom) {
int index = readFrom.indexOf(':');
if (index == -1) {
return ReadFrom.valueOf(getCanonicalReadFromName(readFrom));
}
String name = getCanonicalReadFromName(readFrom.substring(0, index));
String value = readFrom.substring(index + 1);
return ReadFrom.valueOf(name + ":" + value);
}
private String getCanonicalReadFromName(String name) {
StringBuilder canonicalName = new StringBuilder(name.length());
name.chars()
.filter(Character::isLetterOrDigit)
.map(Character::toLowerCase)
.forEach((c) -> canonicalName.append((char) c));
return canonicalName.toString();
}
private ClientOptions createClientOptions(
ObjectProvider<LettuceClientOptionsBuilderCustomizer> clientConfigurationBuilderCustomizers) {
ClientOptions.Builder builder = initializeClientOptionsBuilder();

View File

@ -467,6 +467,11 @@ public class RedisProperties {
*/
private Duration shutdownTimeout = Duration.ofMillis(100);
/**
* Defines from which Redis nodes data is read.
*/
private String readFrom;
/**
* Lettuce pool configuration.
*/
@ -482,6 +487,14 @@ public class RedisProperties {
this.shutdownTimeout = shutdownTimeout;
}
public void setReadFrom(String readFrom) {
this.readFrom = readFrom;
}
public String getReadFrom() {
return this.readFrom;
}
public Pool getPool() {
return this.pool;
}

View File

@ -2924,6 +2924,52 @@
}
]
},
{
"name": "spring.data.redis.lettuce.read-from",
"values": [
{
"value": "any",
"description": "Read from any node."
},
{
"value": "any-replica",
"description": "Read from any replica node."
},
{
"value": "lowest-latency",
"description": "Read from the node with the lowest latency during topology discovery."
},
{
"value": "regex:",
"description": "Read from any node that has RedisURI matching with the given pattern."
},
{
"value": "replica",
"description": "Read from the replica only."
},
{
"value": "replica-preferred",
"description": "Read preferred from replica and fall back to upstream if no replica is available."
},
{
"value": "subnet:",
"description": "Read from any node in the subnets."
},
{
"value": "upstream",
"description": "Read from the upstream only."
},
{
"value": "upstream-preferred",
"description": "Read preferred from the upstream and fall back to a replica if the upstream is not available."
}
],
"providers": [
{
"name": "any"
}
]
},
{
"name": "spring.datasource.data",
"providers": [

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,20 +19,30 @@ package org.springframework.boot.autoconfigure.data.redis;
import java.time.Duration;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.ReadFrom.Nodes;
import io.lettuce.core.RedisURI;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions.RefreshTrigger;
import io.lettuce.core.cluster.models.partitions.RedisClusterNode;
import io.lettuce.core.models.role.RedisNodeDescription;
import io.lettuce.core.resource.DefaultClientResources;
import io.lettuce.core.tracing.Tracing;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledForJreRange;
import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool;
@ -112,6 +122,60 @@ class RedisAutoConfigurationTests {
});
}
@ParameterizedTest(name = "{0}")
@MethodSource
void shouldConfigureLettuceReadFromProperty(String type, ReadFrom readFrom) {
this.contextRunner.withPropertyValues("spring.data.redis.lettuce.read-from:" + type).run((context) -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
LettuceClientConfiguration configuration = factory.getClientConfiguration();
assertThat(configuration.getReadFrom()).hasValue(readFrom);
});
}
static Stream<Arguments> shouldConfigureLettuceReadFromProperty() {
return Stream.of(Arguments.of("any", ReadFrom.ANY), Arguments.of("any-replica", ReadFrom.ANY_REPLICA),
Arguments.of("lowest-latency", ReadFrom.LOWEST_LATENCY), Arguments.of("replica", ReadFrom.REPLICA),
Arguments.of("replica-preferred", ReadFrom.REPLICA_PREFERRED),
Arguments.of("upstream", ReadFrom.UPSTREAM),
Arguments.of("upstream-preferred", ReadFrom.UPSTREAM_PREFERRED));
}
@Test
void shouldConfigureLettuceRegexReadFromProperty() {
RedisClusterNode node1 = createRedisNode("redis-node-1.region-1.example.com");
RedisClusterNode node2 = createRedisNode("redis-node-2.region-1.example.com");
RedisClusterNode node3 = createRedisNode("redis-node-1.region-2.example.com");
RedisClusterNode node4 = createRedisNode("redis-node-2.region-2.example.com");
this.contextRunner.withPropertyValues("spring.data.redis.lettuce.read-from:regex:.*region-1.*")
.run((context) -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
LettuceClientConfiguration configuration = factory.getClientConfiguration();
assertThat(configuration.getReadFrom()).hasValueSatisfying((readFrom) -> {
List<RedisNodeDescription> result = readFrom.select(new RedisNodes(node1, node2, node3, node4));
assertThat(result).hasSize(2).containsExactly(node1, node2);
});
});
}
@Test
void shouldConfigureLettuceSubnetReadFromProperty() {
RedisClusterNode nodeInSubnetIpv4 = createRedisNode("192.0.2.1");
RedisClusterNode nodeNotInSubnetIpv4 = createRedisNode("198.51.100.1");
RedisClusterNode nodeInSubnetIpv6 = createRedisNode("2001:db8:abcd:0000::1");
RedisClusterNode nodeNotInSubnetIpv6 = createRedisNode("2001:db8:abcd:1000::");
this.contextRunner
.withPropertyValues("spring.data.redis.lettuce.read-from:subnet:192.0.2.0/24,2001:db8:abcd:0000::/52")
.run((context) -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
LettuceClientConfiguration configuration = factory.getClientConfiguration();
assertThat(configuration.getReadFrom()).hasValueSatisfying((readFrom) -> {
List<RedisNodeDescription> result = readFrom.select(new RedisNodes(nodeInSubnetIpv4,
nodeNotInSubnetIpv4, nodeInSubnetIpv6, nodeNotInSubnetIpv6));
assertThat(result).hasSize(2).containsExactly(nodeInSubnetIpv4, nodeInSubnetIpv6);
});
});
}
@Test
void testCustomizeClientResources() {
Tracing tracing = mock(Tracing.class);
@ -632,6 +696,32 @@ class RedisAutoConfigurationTests {
return ReflectionTestUtils.invokeMethod(factory, "getRedisUsername");
}
private RedisClusterNode createRedisNode(String host) {
RedisClusterNode node = new RedisClusterNode();
node.setUri(RedisURI.Builder.redis(host).build());
return node;
}
private static final class RedisNodes implements Nodes {
private final List<RedisNodeDescription> descriptions;
RedisNodes(RedisNodeDescription... descriptions) {
this.descriptions = List.of(descriptions);
}
@Override
public List<RedisNodeDescription> getNodes() {
return this.descriptions;
}
@Override
public Iterator<RedisNodeDescription> iterator() {
return this.descriptions.iterator();
}
}
@Configuration(proxyBeanMethods = false)
static class CustomConfiguration {