Skip to content

Commit 954c686

Browse files
thachlpggivo
andauthored
Fix memory leak in JedisClusterInfoCache - replica nodes not cleared (#4205)
* Update reset on JedisClusterInfoCache * Fix the test Try to run mvn formatter:format Try to format the file Revert "Try to format the file" This reverts commit 535c051. Format ControlCommandsTest * Add unit test for JedisClusterInfoCache Add test to ensure that the cluster info cache correctly manages replica nodes after topology changes and resets. - Added scenarios to test rediscovery of cluster nodes and slots, especially handling the removal and reappearance of replica nodes. - Enhanced unit tests to verify that calling reset() properly clears replica slot information. * revert formating only changes * format updated code --------- Co-authored-by: ggivo <ivo.gaydazhiev@redis.com>
1 parent a25d04d commit 954c686

3 files changed

Lines changed: 341 additions & 17 deletions

File tree

src/main/java/redis/clients/jedis/JedisClusterInfoCache.java

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.List;
1111
import java.util.Map;
1212
import java.util.Map.Entry;
13+
import java.util.Objects;
1314
import java.util.Set;
1415

1516
import java.util.concurrent.Executors;
@@ -245,8 +246,7 @@ private void discoverClusterSlots(Connection jedis) {
245246
}
246247
w.lock();
247248
try {
248-
Arrays.fill(slots, null);
249-
Arrays.fill(slotNodes, null);
249+
resetSlots();
250250
if (clientSideCache != null) {
251251
clientSideCache.flush();
252252
}
@@ -442,23 +442,41 @@ public List<ConnectionPool> getShuffledNodesPool() {
442442
public void reset() {
443443
w.lock();
444444
try {
445-
for (ConnectionPool pool : nodes.values()) {
446-
try {
447-
if (pool != null) {
448-
pool.destroy();
449-
}
450-
} catch (RuntimeException e) {
451-
// pass
452-
}
453-
}
454-
nodes.clear();
455-
Arrays.fill(slots, null);
456-
Arrays.fill(slotNodes, null);
445+
resetNodes();
446+
resetSlots();
457447
} finally {
458448
w.unlock();
459449
}
460450
}
461451

452+
private void resetSlots() {
453+
Arrays.fill(slots, null);
454+
Arrays.fill(slotNodes, null);
455+
resetReplicaSlots();
456+
}
457+
458+
private void resetReplicaSlots() {
459+
if (replicaSlots == null) {
460+
return;
461+
}
462+
463+
Arrays.stream(replicaSlots).filter(Objects::nonNull).forEach(List::clear);
464+
Arrays.fill(replicaSlots, null);
465+
}
466+
467+
private void resetNodes() {
468+
for (ConnectionPool pool : nodes.values()) {
469+
try {
470+
if (pool != null) {
471+
pool.destroy();
472+
}
473+
} catch (RuntimeException e) {
474+
// pass
475+
}
476+
}
477+
nodes.clear();
478+
}
479+
462480
public void close() {
463481
reset();
464482
if (topologyRefreshExecutor != null) {
@@ -468,13 +486,14 @@ public void close() {
468486
}
469487

470488
public static String getNodeKey(HostAndPort hnp) {
471-
//return hnp.getHost() + ":" + hnp.getPort();
472489
return hnp.toString();
473490
}
474491

492+
@SuppressWarnings("unchecked")
475493
private List<Object> executeClusterSlots(Connection jedis) {
476-
jedis.sendCommand(Protocol.Command.CLUSTER, "SLOTS");
477-
return jedis.getObjectMultiBulkReply();
494+
CommandArguments clusterSlotsCmd = new ClusterCommandArguments(Protocol.Command.CLUSTER).add(
495+
"SLOTS");
496+
return (List<Object>) jedis.executeCommand(clusterSlotsCmd);
478497
}
479498

480499
private List<Integer> getAssignedSlotArray(List<Object> slotInfo) {
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package redis.clients.jedis;
2+
3+
import org.junit.jupiter.api.Tag;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.api.extension.ExtendWith;
6+
import org.mockito.Mock;
7+
import org.mockito.junit.jupiter.MockitoExtension;
8+
9+
import java.util.ArrayList;
10+
import java.util.Arrays;
11+
import java.util.Collections;
12+
import java.util.HashSet;
13+
import java.util.List;
14+
import java.util.Set;
15+
import java.util.stream.Collectors;
16+
17+
import static org.hamcrest.MatcherAssert.assertThat;
18+
import static org.hamcrest.Matchers.hasItem;
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertNull;
22+
import static org.mockito.ArgumentMatchers.argThat;
23+
import static org.mockito.Mockito.when;
24+
import static redis.clients.jedis.Protocol.Command.CLUSTER;
25+
import static redis.clients.jedis.util.CommandArgumentMatchers.commandWithArgs;
26+
27+
@Tag("unit")
28+
@ExtendWith(MockitoExtension.class)
29+
public class JedisClusterInfoCacheTest {
30+
31+
private static final HostAndPort MASTER_HOST = new HostAndPort("127.0.0.1", 7000);
32+
private static final HostAndPort REPLICA_1_HOST = new HostAndPort("127.0.0.1", 7001);
33+
private static final HostAndPort REPLICA_2_HOST = new HostAndPort("127.0.0.1", 7002);
34+
private static final int TEST_SLOT = 0;
35+
36+
@Mock
37+
private Connection mockConnection;
38+
39+
@Test
40+
public void testReplicaNodeRemovalAndRediscovery() {
41+
// Create client config with read-only replicas enabled
42+
JedisClientConfig clientConfig = DefaultJedisClientConfig.builder()
43+
.readOnlyForRedisClusterReplicas().build();
44+
45+
Set<HostAndPort> startNodes = new HashSet<>();
46+
startNodes.add(MASTER_HOST);
47+
48+
JedisClusterInfoCache cache = new JedisClusterInfoCache(clientConfig, startNodes);
49+
50+
// Mock the cluster slots responses
51+
when(mockConnection.executeCommand(argThat(commandWithArgs(CLUSTER, "SLOTS")))).thenReturn(
52+
masterReplicaSlotsResponse()).thenReturn(masterOnlySlotsResponse())
53+
.thenReturn(masterReplica2SlotsResponse());
54+
55+
// Initial discovery with one master and one replica (replica-1)
56+
cache.discoverClusterNodesAndSlots(mockConnection);
57+
assertMasterNodeAvailable(cache);
58+
assertReplicasAvailable(cache, REPLICA_1_HOST);
59+
60+
// Simulate rediscovery - master only
61+
cache.discoverClusterNodesAndSlots(mockConnection);
62+
// Master should still be available
63+
// Replica should be cleared
64+
assertMasterNodeAvailable(cache);
65+
assertNoReplicasAvailable(cache);
66+
67+
// Simulate rediscovery - another replica (replica-2) coming back
68+
cache.reset();
69+
cache.discoverClusterNodesAndSlots(mockConnection);
70+
assertReplicasAvailable(cache, REPLICA_2_HOST);
71+
}
72+
73+
@Test
74+
public void testResetWithReplicaSlots() {
75+
// This test verifies that reset() properly clears replica slots
76+
77+
JedisClusterInfoCache cache = createCacheWithReplicasEnabled();
78+
79+
// Mock the cluster slots responses
80+
when(mockConnection.executeCommand(argThat(commandWithArgs(CLUSTER, "SLOTS")))).thenReturn(
81+
masterReplicaSlotsResponse());
82+
83+
// Initial discovery
84+
cache.discoverClusterNodesAndSlots(mockConnection);
85+
assertReplicasAvailable(cache, REPLICA_1_HOST);
86+
87+
// Call reset() - this should clear and nullify replica slots
88+
cache.reset();
89+
90+
assertNoReplicasAvailable(cache);
91+
92+
// Rediscovery should work correctly
93+
cache.discoverClusterNodesAndSlots(mockConnection);
94+
assertReplicasAvailable(cache, REPLICA_1_HOST);
95+
}
96+
97+
private List<Object> masterReplicaSlotsResponse() {
98+
return createClusterSlotsResponse(
99+
new SlotRange.Builder(0, 16383).master(MASTER_HOST, "master-id-1")
100+
.replica(REPLICA_1_HOST, "replica-id-1").build());
101+
}
102+
103+
private List<Object> masterOnlySlotsResponse() {
104+
return createClusterSlotsResponse(
105+
new SlotRange.Builder(0, 16383).master(MASTER_HOST, "master-id-1").build());
106+
}
107+
108+
private List<Object> masterReplica2SlotsResponse() {
109+
return createClusterSlotsResponse(
110+
new SlotRange.Builder(0, 16383).master(MASTER_HOST, "master-id-1")
111+
.replica(REPLICA_2_HOST, "replica-id-2").build());
112+
}
113+
114+
private JedisClusterInfoCache createCacheWithReplicasEnabled() {
115+
116+
JedisClientConfig clientConfig = DefaultJedisClientConfig.builder()
117+
.readOnlyForRedisClusterReplicas().build();
118+
119+
return new JedisClusterInfoCache(clientConfig,
120+
new HashSet<>(Collections.singletonList(MASTER_HOST)));
121+
}
122+
123+
private void assertNoReplicasAvailable(JedisClusterInfoCache cache) {
124+
List<ConnectionPool> caheReplicaNodePools = cache.getSlotReplicaPools(TEST_SLOT);
125+
assertNull(caheReplicaNodePools);
126+
}
127+
128+
private void assertReplicasAvailable(JedisClusterInfoCache cache, HostAndPort... replicaNodes) {
129+
List<ConnectionPool> caheReplicaNodePools = cache.getSlotReplicaPools(TEST_SLOT);
130+
assertEquals(replicaNodes.length, caheReplicaNodePools.size());
131+
for (HostAndPort expectedReplica : replicaNodes) {
132+
ConnectionPool expectedNodePool = cache.getNode(expectedReplica);
133+
assertThat(caheReplicaNodePools, hasItem(expectedNodePool));
134+
}
135+
}
136+
137+
private void assertMasterNodeAvailable(JedisClusterInfoCache cache) {
138+
HostAndPort masterNode = cache.getSlotNode(TEST_SLOT);
139+
assertNotNull(masterNode);
140+
assertEquals(MASTER_HOST, masterNode);
141+
}
142+
143+
/**
144+
* Helper method to create a cluster slots response with master and replica nodes
145+
*/
146+
private List<Object> createClusterSlotsResponse(SlotRange... slotRanges) {
147+
return Arrays.stream(slotRanges).map(this::clusterSlotRange).collect(Collectors.toList());
148+
}
149+
150+
private List<Object> clusterSlotRange(SlotRange slotRange) {
151+
List<Object> slotInfo = new ArrayList<>();
152+
slotInfo.add((long) slotRange.start);
153+
slotInfo.add((long) slotRange.end);
154+
Node master = slotRange.master();
155+
slotInfo.add(
156+
Arrays.asList(master.getHost().getBytes(), (long) master.getPort(), master.id.getBytes()));
157+
// Add replicas
158+
slotRange.replicas().forEach(r -> slotInfo.add(
159+
Arrays.asList(r.getHost().getBytes(), (long) r.getPort(), r.id.getBytes())));
160+
return slotInfo;
161+
}
162+
163+
static class SlotRange {
164+
private final int start;
165+
private final int end;
166+
private final List<Node> nodes;
167+
168+
private SlotRange(int start, int end, List<Node> nodes) {
169+
this.start = start;
170+
this.end = end;
171+
this.nodes = nodes;
172+
}
173+
174+
public SlotRange.Builder builder(int start, int end) {
175+
return new SlotRange.Builder(start, end);
176+
}
177+
178+
public Node master() {
179+
return nodes.get(0);
180+
}
181+
182+
public List<Node> replicas() {
183+
return nodes.subList(1, nodes.size());
184+
}
185+
186+
static class Builder {
187+
private final int start;
188+
private final int end;
189+
private final List<Node> nodes = new ArrayList<>();
190+
191+
public Builder(int start, int end) {
192+
this.start = start;
193+
this.end = end;
194+
}
195+
196+
public Builder master(Node node) {
197+
if (!nodes.isEmpty()) {
198+
nodes.set(0, node);
199+
} else {
200+
nodes.add(node);
201+
}
202+
return this;
203+
}
204+
205+
public Builder master(HostAndPort hostPort, String id) {
206+
return master(new Node(hostPort, id));
207+
}
208+
209+
public Builder replica(HostAndPort hostPort, String id) {
210+
return replica(new Node(hostPort, id));
211+
}
212+
213+
public Builder replica(Node node) {
214+
if (nodes.isEmpty()) {
215+
throw new IllegalStateException("Master node must be added before adding replicas");
216+
}
217+
nodes.add(node);
218+
return this;
219+
}
220+
221+
public SlotRange build() {
222+
return new SlotRange(start, end, nodes);
223+
}
224+
225+
}
226+
227+
}
228+
229+
static class Node {
230+
private final HostAndPort hostPort;
231+
private final String id;
232+
233+
public Node(HostAndPort hostPort, String id) {
234+
this.hostPort = hostPort;
235+
this.id = id;
236+
}
237+
238+
public HostAndPort getHostPort() {
239+
return hostPort;
240+
}
241+
242+
public String getHost() {
243+
return hostPort.getHost();
244+
}
245+
246+
public int getPort() {
247+
return hostPort.getPort();
248+
}
249+
250+
public String getId() {
251+
return id;
252+
}
253+
254+
}
255+
256+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package redis.clients.jedis.util;
2+
3+
import org.mockito.ArgumentMatcher;
4+
import redis.clients.jedis.CommandArguments;
5+
import redis.clients.jedis.args.Rawable;
6+
import redis.clients.jedis.commands.ProtocolCommand;
7+
8+
/**
9+
* Utility class providing Mockito ArgumentMatchers for CommandArguments testing.
10+
*/
11+
public final class CommandArgumentMatchers {
12+
13+
private CommandArgumentMatchers() {
14+
throw new InstantiationError("Must not instantiate this class");
15+
}
16+
17+
/**
18+
* Matcher for CommandArguments with specific ProtocolCommand
19+
*/
20+
public static ArgumentMatcher<CommandArguments> commandIs(ProtocolCommand command) {
21+
return args -> {
22+
if (args == null || !(args instanceof CommandArguments)) {
23+
return false;
24+
}
25+
return command.equals(args.getCommand());
26+
};
27+
}
28+
29+
/**
30+
* Matcher for CommandArguments containing specific arguments
31+
*/
32+
public static ArgumentMatcher<CommandArguments> hasArgument(String expectedArg) {
33+
return args -> {
34+
for (Rawable arg : args) {
35+
36+
if (expectedArg.equals(SafeEncoder.encode(arg.getRaw()))) {
37+
return true;
38+
}
39+
}
40+
return false;
41+
};
42+
}
43+
44+
public static ArgumentMatcher<CommandArguments> commandWithArgs(ProtocolCommand command,
45+
String expectedArg) {
46+
return cmd -> commandIs(command).matches(cmd) && hasArgument(expectedArg).matches(cmd);
47+
}
48+
49+
}

0 commit comments

Comments
 (0)