Skip to content

Commit db8e164

Browse files
committed
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
2 parents 9430a47 + e4bcda2 commit db8e164

File tree

4 files changed

+174
-1
lines changed

4 files changed

+174
-1
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java

+24
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.time.Duration;
2020

2121
import io.lettuce.core.ClientOptions;
22+
import io.lettuce.core.ReadFrom;
2223
import io.lettuce.core.RedisClient;
2324
import io.lettuce.core.SocketOptions;
2425
import io.lettuce.core.TimeoutOptions;
@@ -163,12 +164,35 @@ private void applyProperties(LettuceClientConfiguration.LettuceClientConfigurati
163164
if (lettuce.getShutdownTimeout() != null && !lettuce.getShutdownTimeout().isZero()) {
164165
builder.shutdownTimeout(getProperties().getLettuce().getShutdownTimeout());
165166
}
167+
String readFrom = lettuce.getReadFrom();
168+
if (readFrom != null) {
169+
builder.readFrom(getReadFrom(readFrom));
170+
}
166171
}
167172
if (StringUtils.hasText(getProperties().getClientName())) {
168173
builder.clientName(getProperties().getClientName());
169174
}
170175
}
171176

177+
private ReadFrom getReadFrom(String readFrom) {
178+
int index = readFrom.indexOf(':');
179+
if (index == -1) {
180+
return ReadFrom.valueOf(getCanonicalReadFromName(readFrom));
181+
}
182+
String name = getCanonicalReadFromName(readFrom.substring(0, index));
183+
String value = readFrom.substring(index + 1);
184+
return ReadFrom.valueOf(name + ":" + value);
185+
}
186+
187+
private String getCanonicalReadFromName(String name) {
188+
StringBuilder canonicalName = new StringBuilder(name.length());
189+
name.chars()
190+
.filter(Character::isLetterOrDigit)
191+
.map(Character::toLowerCase)
192+
.forEach((c) -> canonicalName.append((char) c));
193+
return canonicalName.toString();
194+
}
195+
172196
private ClientOptions createClientOptions(
173197
ObjectProvider<LettuceClientOptionsBuilderCustomizer> clientConfigurationBuilderCustomizers) {
174198
ClientOptions.Builder builder = initializeClientOptionsBuilder();

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java

+13
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,11 @@ public static class Lettuce {
467467
*/
468468
private Duration shutdownTimeout = Duration.ofMillis(100);
469469

470+
/**
471+
* Defines from which Redis nodes data is read.
472+
*/
473+
private String readFrom;
474+
470475
/**
471476
* Lettuce pool configuration.
472477
*/
@@ -482,6 +487,14 @@ public void setShutdownTimeout(Duration shutdownTimeout) {
482487
this.shutdownTimeout = shutdownTimeout;
483488
}
484489

490+
public void setReadFrom(String readFrom) {
491+
this.readFrom = readFrom;
492+
}
493+
494+
public String getReadFrom() {
495+
return this.readFrom;
496+
}
497+
485498
public Pool getPool() {
486499
return this.pool;
487500
}

spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json

+46
Original file line numberDiff line numberDiff line change
@@ -2924,6 +2924,52 @@
29242924
}
29252925
]
29262926
},
2927+
{
2928+
"name": "spring.data.redis.lettuce.read-from",
2929+
"values": [
2930+
{
2931+
"value": "any",
2932+
"description": "Read from any node."
2933+
},
2934+
{
2935+
"value": "any-replica",
2936+
"description": "Read from any replica node."
2937+
},
2938+
{
2939+
"value": "lowest-latency",
2940+
"description": "Read from the node with the lowest latency during topology discovery."
2941+
},
2942+
{
2943+
"value": "regex:",
2944+
"description": "Read from any node that has RedisURI matching with the given pattern."
2945+
},
2946+
{
2947+
"value": "replica",
2948+
"description": "Read from the replica only."
2949+
},
2950+
{
2951+
"value": "replica-preferred",
2952+
"description": "Read preferred from replica and fall back to upstream if no replica is available."
2953+
},
2954+
{
2955+
"value": "subnet:",
2956+
"description": "Read from any node in the subnets."
2957+
},
2958+
{
2959+
"value": "upstream",
2960+
"description": "Read from the upstream only."
2961+
},
2962+
{
2963+
"value": "upstream-preferred",
2964+
"description": "Read preferred from the upstream and fall back to a replica if the upstream is not available."
2965+
}
2966+
],
2967+
"providers": [
2968+
{
2969+
"name": "any"
2970+
}
2971+
]
2972+
},
29272973
{
29282974
"name": "spring.datasource.data",
29292975
"providers": [

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java

+91-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,20 +19,30 @@
1919
import java.time.Duration;
2020
import java.util.Arrays;
2121
import java.util.EnumSet;
22+
import java.util.Iterator;
2223
import java.util.List;
2324
import java.util.Set;
2425
import java.util.function.Consumer;
2526
import java.util.stream.Collectors;
27+
import java.util.stream.Stream;
2628

2729
import io.lettuce.core.ClientOptions;
30+
import io.lettuce.core.ReadFrom;
31+
import io.lettuce.core.ReadFrom.Nodes;
32+
import io.lettuce.core.RedisURI;
2833
import io.lettuce.core.cluster.ClusterClientOptions;
2934
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions.RefreshTrigger;
35+
import io.lettuce.core.cluster.models.partitions.RedisClusterNode;
36+
import io.lettuce.core.models.role.RedisNodeDescription;
3037
import io.lettuce.core.resource.DefaultClientResources;
3138
import io.lettuce.core.tracing.Tracing;
3239
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
3340
import org.junit.jupiter.api.Test;
3441
import org.junit.jupiter.api.condition.EnabledForJreRange;
3542
import org.junit.jupiter.api.condition.JRE;
43+
import org.junit.jupiter.params.ParameterizedTest;
44+
import org.junit.jupiter.params.provider.Arguments;
45+
import org.junit.jupiter.params.provider.MethodSource;
3646

3747
import org.springframework.boot.autoconfigure.AutoConfigurations;
3848
import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool;
@@ -112,6 +122,60 @@ void testOverrideRedisConfiguration() {
112122
});
113123
}
114124

125+
@ParameterizedTest(name = "{0}")
126+
@MethodSource
127+
void shouldConfigureLettuceReadFromProperty(String type, ReadFrom readFrom) {
128+
this.contextRunner.withPropertyValues("spring.data.redis.lettuce.read-from:" + type).run((context) -> {
129+
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
130+
LettuceClientConfiguration configuration = factory.getClientConfiguration();
131+
assertThat(configuration.getReadFrom()).hasValue(readFrom);
132+
});
133+
}
134+
135+
static Stream<Arguments> shouldConfigureLettuceReadFromProperty() {
136+
return Stream.of(Arguments.of("any", ReadFrom.ANY), Arguments.of("any-replica", ReadFrom.ANY_REPLICA),
137+
Arguments.of("lowest-latency", ReadFrom.LOWEST_LATENCY), Arguments.of("replica", ReadFrom.REPLICA),
138+
Arguments.of("replica-preferred", ReadFrom.REPLICA_PREFERRED),
139+
Arguments.of("upstream", ReadFrom.UPSTREAM),
140+
Arguments.of("upstream-preferred", ReadFrom.UPSTREAM_PREFERRED));
141+
}
142+
143+
@Test
144+
void shouldConfigureLettuceRegexReadFromProperty() {
145+
RedisClusterNode node1 = createRedisNode("redis-node-1.region-1.example.com");
146+
RedisClusterNode node2 = createRedisNode("redis-node-2.region-1.example.com");
147+
RedisClusterNode node3 = createRedisNode("redis-node-1.region-2.example.com");
148+
RedisClusterNode node4 = createRedisNode("redis-node-2.region-2.example.com");
149+
this.contextRunner.withPropertyValues("spring.data.redis.lettuce.read-from:regex:.*region-1.*")
150+
.run((context) -> {
151+
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
152+
LettuceClientConfiguration configuration = factory.getClientConfiguration();
153+
assertThat(configuration.getReadFrom()).hasValueSatisfying((readFrom) -> {
154+
List<RedisNodeDescription> result = readFrom.select(new RedisNodes(node1, node2, node3, node4));
155+
assertThat(result).hasSize(2).containsExactly(node1, node2);
156+
});
157+
});
158+
}
159+
160+
@Test
161+
void shouldConfigureLettuceSubnetReadFromProperty() {
162+
RedisClusterNode nodeInSubnetIpv4 = createRedisNode("192.0.2.1");
163+
RedisClusterNode nodeNotInSubnetIpv4 = createRedisNode("198.51.100.1");
164+
RedisClusterNode nodeInSubnetIpv6 = createRedisNode("2001:db8:abcd:0000::1");
165+
RedisClusterNode nodeNotInSubnetIpv6 = createRedisNode("2001:db8:abcd:1000::");
166+
this.contextRunner
167+
.withPropertyValues("spring.data.redis.lettuce.read-from:subnet:192.0.2.0/24,2001:db8:abcd:0000::/52")
168+
.run((context) -> {
169+
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
170+
LettuceClientConfiguration configuration = factory.getClientConfiguration();
171+
assertThat(configuration.getReadFrom()).hasValueSatisfying((readFrom) -> {
172+
List<RedisNodeDescription> result = readFrom.select(new RedisNodes(nodeInSubnetIpv4,
173+
nodeNotInSubnetIpv4, nodeInSubnetIpv6, nodeNotInSubnetIpv6));
174+
assertThat(result).hasSize(2).containsExactly(nodeInSubnetIpv4, nodeInSubnetIpv6);
175+
});
176+
});
177+
}
178+
115179
@Test
116180
void testCustomizeClientResources() {
117181
Tracing tracing = mock(Tracing.class);
@@ -632,6 +696,32 @@ private String getUserName(LettuceConnectionFactory factory) {
632696
return ReflectionTestUtils.invokeMethod(factory, "getRedisUsername");
633697
}
634698

699+
private RedisClusterNode createRedisNode(String host) {
700+
RedisClusterNode node = new RedisClusterNode();
701+
node.setUri(RedisURI.Builder.redis(host).build());
702+
return node;
703+
}
704+
705+
private static final class RedisNodes implements Nodes {
706+
707+
private final List<RedisNodeDescription> descriptions;
708+
709+
RedisNodes(RedisNodeDescription... descriptions) {
710+
this.descriptions = List.of(descriptions);
711+
}
712+
713+
@Override
714+
public List<RedisNodeDescription> getNodes() {
715+
return this.descriptions;
716+
}
717+
718+
@Override
719+
public Iterator<RedisNodeDescription> iterator() {
720+
return this.descriptions.iterator();
721+
}
722+
723+
}
724+
635725
@Configuration(proxyBeanMethods = false)
636726
static class CustomConfiguration {
637727

0 commit comments

Comments
 (0)