This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new adf92d4de8 fix(etcd): prevent watcher leaks in selectInstances (#7435)
adf92d4de8 is described below
commit adf92d4de87c7a05d07bc0e2fd44ba20d468bb94
Author: lymerin <[email protected]>
AuthorDate: Thu Oct 8 14:40:41 2026 +0800
fix(etcd): prevent watcher leaks in selectInstances (#7435)
---
.../etcd/EtcdInstanceRegisterRepository.java | 41 +-------
.../etcd/EtcdInstanceRegisterRepositoryTest.java | 107 ++++++++++++++++++---
2 files changed, 97 insertions(+), 51 deletions(-)
diff --git
a/shenyu-registry/shenyu-registry-etcd/src/main/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepository.java
b/shenyu-registry/shenyu-registry-etcd/src/main/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepository.java
index 0acb3f73de..ac6a8656bf 100644
---
a/shenyu-registry/shenyu-registry-etcd/src/main/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepository.java
+++
b/shenyu-registry/shenyu-registry-etcd/src/main/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepository.java
@@ -36,13 +36,10 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
-import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.function.Function;
import java.util.stream.Collectors;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -57,8 +54,6 @@ public class EtcdInstanceRegisterRepository implements
ShenyuInstanceRegisterRep
private EtcdClient client;
- private final Map<String, List<InstanceEntity>> watcherInstanceRegisterMap
= new ConcurrentHashMap<>();
-
private final Multimap<String, Watch.Watcher> watchCache =
ArrayListMultimap.create();
@Override
@@ -89,37 +84,11 @@ public class EtcdInstanceRegisterRepository implements
ShenyuInstanceRegisterRep
@Override
public List<InstanceEntity> selectInstances(final String selectKey) {
final String watchKey =
InstancePathConstants.buildInstanceParentPath(selectKey);
- final Function<Map<String, String>, List<InstanceEntity>>
getInstanceRegisterFun = childrenList ->
- childrenList.values().stream().map(x -> {
- InstanceEntity instanceEntity =
GsonUtils.getInstance().fromJson(x, InstanceEntity.class);
- instanceEntity.setUri(getURI(x, instanceEntity.getPort(),
instanceEntity.getHost()));
- return instanceEntity;
- }).collect(Collectors.toList());
- if (Objects.nonNull(watcherInstanceRegisterMap.get(selectKey))) {
- return
getInstanceRegisterFun.apply(client.getKeysMapByPrefix(watchKey));
- }
- Map<String, String> serverNodes = new
ConcurrentHashMap<>(client.getKeysMapByPrefix(watchKey));
- this.client.watchKeyChanges(watchKey, Watch.listener(response -> {
- for (WatchEvent event : response.getEvents()) {
- String value =
event.getKeyValue().getValue().toString(StandardCharsets.UTF_8);
- String path =
event.getKeyValue().getKey().toString(StandardCharsets.UTF_8);
- switch (event.getEventType()) {
- case PUT:
- serverNodes.put(path, value);
- LOGGER.info("watch key {} updated, value is {}",
watchKey, value);
- continue;
- case DELETE:
- serverNodes.remove(path);
- LOGGER.info("watch key {} deleted, key is {}",
watchKey, path);
- continue;
- default:
- }
- }
- watcherInstanceRegisterMap.put(selectKey,
getInstanceRegisterFun.apply(serverNodes));
- }));
- final List<InstanceEntity> instanceEntities =
getInstanceRegisterFun.apply(serverNodes);
- watcherInstanceRegisterMap.put(selectKey, instanceEntities);
- return instanceEntities;
+ return client.getKeysMapByPrefix(watchKey).values().stream().map(x -> {
+ InstanceEntity instanceEntity =
GsonUtils.getInstance().fromJson(x, InstanceEntity.class);
+ instanceEntity.setUri(getURI(x, instanceEntity.getPort(),
instanceEntity.getHost()));
+ return instanceEntity;
+ }).collect(Collectors.toList());
}
@Override
diff --git
a/shenyu-registry/shenyu-registry-etcd/src/test/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepositoryTest.java
b/shenyu-registry/shenyu-registry-etcd/src/test/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepositoryTest.java
index b0ec00a46f..5ceb3207b1 100644
---
a/shenyu-registry/shenyu-registry-etcd/src/test/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepositoryTest.java
+++
b/shenyu-registry/shenyu-registry-etcd/src/test/java/org/apache/shenyu/registry/etcd/EtcdInstanceRegisterRepositoryTest.java
@@ -17,35 +17,49 @@
package org.apache.shenyu.registry.etcd;
+import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.ClientBuilder;
+import io.etcd.jetcd.KeyValue;
import io.etcd.jetcd.Lease;
+import io.etcd.jetcd.Watch;
import io.etcd.jetcd.lease.LeaseGrantResponse;
+import io.etcd.jetcd.watch.WatchEvent;
+import io.etcd.jetcd.watch.WatchResponse;
import org.apache.shenyu.common.exception.ShenyuException;
import org.apache.shenyu.common.utils.GsonUtils;
import org.apache.shenyu.infra.etcd.client.EtcdClient;
import org.apache.shenyu.registry.api.config.RegisterConfig;
import org.apache.shenyu.registry.api.entity.InstanceEntity;
+import org.apache.shenyu.registry.api.event.ChangedEventListener;
import org.apache.shenyu.registry.api.path.InstancePathConstants;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.mockito.MockedConstruction;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -55,6 +69,8 @@ public final class EtcdInstanceRegisterRepositoryTest {
private EtcdInstanceRegisterRepository repository;
+ private EtcdClient etcdClient;
+
private final Map<String, String> etcdBroker = new HashMap<>();
/**
@@ -77,7 +93,8 @@ public final class EtcdInstanceRegisterRepositoryTest {
}
private EtcdClient mockEtcdClient() {
- EtcdClient etcdClient = mock(EtcdClient.class);
+ etcdClient = mock(EtcdClient.class);
+
when(etcdClient.getKeysMapByPrefix(anyString())).thenReturn(Collections.emptyMap());
doAnswer(invocationOnMock -> {
String key = invocationOnMock.getArgument(0);
@@ -138,25 +155,85 @@ public final class EtcdInstanceRegisterRepositoryTest {
}
/**
- * Test select instances and watcher.
+ * Test selecting instances does not create a persistent watcher.
*/
@Test
- public void testSelectInstancesAndWatcher() {
+ public void testSelectInstancesDoesNotCreateWatcher() {
+ assertTrue(repository.selectInstances("shenyu-test").isEmpty());
+ assertTrue(repository.selectInstances("shenyu-test").isEmpty());
+
+ verify(etcdClient,
times(2)).getKeysMapByPrefix(InstancePathConstants.buildInstanceParentPath("shenyu-test"));
+ verify(etcdClient, never()).watchKeyChanges(anyString(),
any(Watch.Listener.class));
+ }
+
+ /**
+ * Test each selection reads current instances and fills their URIs.
+ */
+ @Test
+ public void testSelectInstancesReadsCurrentInstances() {
InstanceEntity data = InstanceEntity.builder()
.appName("shenyu-test")
.host("shenyu-host")
.port(9195)
.build();
+ String prefix =
InstancePathConstants.buildInstanceParentPath(data.getAppName());
+ String node = InstancePathConstants.buildRealNode(prefix,
"shenyu-host:9195");
+ Map<String, String> serverNodes = new HashMap<>();
+ when(etcdClient.getKeysMapByPrefix(prefix)).thenAnswer(invocation ->
new HashMap<>(serverNodes));
- try (MockedConstruction<EtcdClient> construction =
mockConstruction(EtcdClient.class, (mock, context) -> {
- })) {
- final EtcdInstanceRegisterRepository repository = new
EtcdInstanceRegisterRepository();
- RegisterConfig config = new RegisterConfig();
- config.setServerLists("http://localhost:2379");
- repository.init(config);
- repository.persistInstance(data);
-
repository.selectInstances(InstancePathConstants.buildInstanceParentPath());
- repository.close();
- }
+ assertTrue(repository.selectInstances(data.getAppName()).isEmpty());
+ serverNodes.put(node, GsonUtils.getInstance().toJson(data));
+ List<InstanceEntity> instances =
repository.selectInstances(data.getAppName());
+ assertEquals(1, instances.size());
+ assertEquals(data.getAppName(), instances.get(0).getAppName());
+ assertEquals(data.getHost(), instances.get(0).getHost());
+ assertEquals(data.getPort(), instances.get(0).getPort());
+ assertEquals(URI.create("http://shenyu-host:9195"),
instances.get(0).getUri());
+
+ data.setWeight(10);
+ data.setUri(URI.create("https://shenyu-host:9195"));
+ serverNodes.put(node, GsonUtils.getInstance().toJson(data));
+ instances = repository.selectInstances(data.getAppName());
+ assertEquals(1, instances.size());
+ assertEquals(10, instances.get(0).getWeight());
+ assertEquals(URI.create("https://shenyu-host:9195"),
instances.get(0).getUri());
+
+ InstanceEntity another = new InstanceEntity(data.getAppName(),
"another-host", 9196);
+ serverNodes.put(InstancePathConstants.buildRealNode(prefix,
"another-host:9196"), GsonUtils.getInstance().toJson(another));
+ assertEquals(2, repository.selectInstances(data.getAppName()).size());
+ serverNodes.remove(node);
+ instances = repository.selectInstances(data.getAppName());
+ assertEquals(1, instances.size());
+ assertEquals(URI.create("http://another-host:9196"),
instances.get(0).getUri());
+ serverNodes.clear();
+ assertTrue(repository.selectInstances(data.getAppName()).isEmpty());
+ verify(etcdClient, times(6)).getKeysMapByPrefix(prefix);
+ }
+
+ /**
+ * Test selecting instances preserves an explicit watcher and its
notifications.
+ */
+ @Test
+ public void testSelectInstancesPreservesExplicitWatcher() {
+ String key =
InstancePathConstants.buildInstanceParentPath("shenyu-test");
+ ChangedEventListener listener = mock(ChangedEventListener.class);
+ Watch.Watcher watcher = mock(Watch.Watcher.class);
+ when(etcdClient.watchKeyChanges(eq(key),
any(Watch.Listener.class))).thenReturn(watcher);
+
+ repository.watchInstances(key, listener);
+ repository.selectInstances("shenyu-test");
+ ArgumentCaptor<Watch.Listener> captor =
ArgumentCaptor.forClass(Watch.Listener.class);
+ verify(etcdClient).watchKeyChanges(eq(key), captor.capture());
+ verify(watcher, never()).close();
+ String node = key + "/shenyu-host:9195";
+ String value = "instance-data";
+ KeyValue keyValue = mock(KeyValue.class);
+ when(keyValue.getKey()).thenReturn(ByteSequence.from(node, UTF_8));
+ when(keyValue.getValue()).thenReturn(ByteSequence.from(value, UTF_8));
+ WatchResponse response = mock(WatchResponse.class);
+ when(response.getEvents()).thenReturn(Collections.singletonList(new
WatchEvent(keyValue, keyValue, WatchEvent.EventType.PUT)));
+
+ captor.getValue().onNext(response);
+ verify(listener).onEvent(node, value,
ChangedEventListener.Event.ADDED);
}
}