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 af9d695e87 fix(admin): pull API docs once per upstream event and skip 
the lock when already refreshed (#7872)
af9d695e87 is described below

commit af9d695e8776f0ece545b0118fef8a39fc05191e
Author: Duansg <[email protected]>
AuthorDate: Tue Oct 20 00:07:42 2026 -0700

    fix(admin): pull API docs once per upstream event and skip the lock when 
already refreshed (#7872)
---
 .../admin/listener/DataChangedEventDispatcher.java | 113 +++++++++++--------
 .../manager/impl/PullSwaggerDocServiceImpl.java    |  27 +++--
 .../listener/DataChangedEventDispatcherTest.java   | 124 ++++++++++++++++++++-
 .../manager/impl/SwaggerDocManagerImplTest.java    | 112 +++++++++++++++++++
 4 files changed, 321 insertions(+), 55 deletions(-)

diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcher.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcher.java
index 8fa4edb188..385a1ab3d1 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcher.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcher.java
@@ -96,53 +96,78 @@ public class DataChangedEventDispatcher implements 
ApplicationListener<DataChang
         if (!master) {
             forwardEventToMaster(event);
         }
-        for (DataChangedListener listener : listeners) {
-            if (!master && !(listener instanceof AbstractDataChangedListener)) 
{
-                // push listeners run on the master only; the event was handed 
over above
-                continue;
+        boolean loadDoc = false;
+        Throwable listenerFailure = null;
+        try {
+            for (DataChangedListener listener : listeners) {
+                if (!master && !(listener instanceof 
AbstractDataChangedListener)) {
+                    // push listeners run on the master only; the event was 
handed over above
+                    continue;
+                }
+                final int size = event.getSource() instanceof 
java.util.Collection
+                        ? ((java.util.Collection<?>) event.getSource()).size() 
: 1;
+                LOG.info("received DataChangedEvent, group={}, size={}, 
type={}",
+                        event.getGroupKey(), size, event.getEventType());
+                if (LOG.isDebugEnabled()) {
+                    LOG.debug("received DataChangedEvent payload: {}", 
JsonUtils.toJson(event));
+                }
+                switch (event.getGroupKey()) {
+                    case APP_AUTH:
+                        listener.onAppAuthChanged((List<AppAuthData>) 
event.getSource(),
+                                event.getEventType(), event.getNamespaceId());
+                        break;
+                    case PLUGIN:
+                        listener.onPluginChanged((List<PluginData>) 
event.getSource(),
+                                event.getEventType(), event.getNamespaceId());
+                        break;
+                    case RULE:
+                        listener.onRuleChanged((List<RuleData>) 
event.getSource(),
+                                event.getEventType(), event.getNamespaceId());
+                        break;
+                    case SELECTOR:
+                        listener.onSelectorChanged((List<SelectorData>) 
event.getSource(),
+                                event.getEventType(), event.getNamespaceId());
+                        break;
+                    case META_DATA:
+                        listener.onMetaDataChanged((List<MetaData>) 
event.getSource(), event.getEventType());
+                        break;
+                    case PROXY_SELECTOR:
+                        
listener.onProxySelectorChanged((List<ProxySelectorData>) event.getSource(), 
event.getEventType());
+                        break;
+                    case AI_PROXY_API_KEY:
+                        
listener.onAiProxyApiKeyChanged((List<ProxyApiKeyData>) event.getSource(), 
event.getEventType());
+                        break;
+                    case DISCOVER_UPSTREAM:
+                        
listener.onDiscoveryUpstreamChanged((List<DiscoverySyncData>) event.getSource(),
+                                event.getEventType());
+                        loadDoc = true;
+                        break;
+                    default:
+                        throw new IllegalStateException("Unexpected value: " + 
event.getGroupKey());
+                }
             }
-            final int size = event.getSource() instanceof java.util.Collection
-                    ? ((java.util.Collection<?>) event.getSource()).size() : 1;
-            LOG.info("received DataChangedEvent, group={}, size={}, type={}",
-                    event.getGroupKey(), size, event.getEventType());
-            if (LOG.isDebugEnabled()) {
-                LOG.debug("received DataChangedEvent payload: {}", 
JsonUtils.toJson(event));
+        } catch (Throwable ex) {
+            listenerFailure = ex;
+            throw ex;
+        } finally {
+            // load once per event, even if a later listener failed after the 
upstream change was applied
+            if (loadDoc) {
+                loadDocOnUpstreamChanged(event, listenerFailure);
             }
-            switch (event.getGroupKey()) {
-                case APP_AUTH:
-                    listener.onAppAuthChanged((List<AppAuthData>) 
event.getSource(),
-                            event.getEventType(), event.getNamespaceId());
-                    break;
-                case PLUGIN:
-                    listener.onPluginChanged((List<PluginData>) 
event.getSource(),
-                            event.getEventType(), event.getNamespaceId());
-                    break;
-                case RULE:
-                    listener.onRuleChanged((List<RuleData>) event.getSource(),
-                            event.getEventType(), event.getNamespaceId());
-                    break;
-                case SELECTOR:
-                    listener.onSelectorChanged((List<SelectorData>) 
event.getSource(),
-                            event.getEventType(), event.getNamespaceId());
-                    break;
-                case META_DATA:
-                    listener.onMetaDataChanged((List<MetaData>) 
event.getSource(), event.getEventType());
-                    break;
-                case PROXY_SELECTOR:
-                    listener.onProxySelectorChanged((List<ProxySelectorData>) 
event.getSource(), event.getEventType());
-                    break;
-                case AI_PROXY_API_KEY:
-                    listener.onAiProxyApiKeyChanged((List<ProxyApiKeyData>) 
event.getSource(), event.getEventType());
-                    break;
-                case DISCOVER_UPSTREAM:
-                    
listener.onDiscoveryUpstreamChanged((List<DiscoverySyncData>) event.getSource(),
-                            event.getEventType());
-                    
applicationContext.getBean(LoadServiceDocEntry.class).loadDocOnUpstreamChanged(
-                            (List<DiscoverySyncData>) event.getSource(), 
event.getEventType());
-                    break;
-                default:
-                    throw new IllegalStateException("Unexpected value: " + 
event.getGroupKey());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void loadDocOnUpstreamChanged(final DataChangedEvent event, final 
Throwable listenerFailure) {
+        try {
+            
applicationContext.getBean(LoadServiceDocEntry.class).loadDocOnUpstreamChanged(
+                    (List<DiscoverySyncData>) event.getSource(), 
event.getEventType());
+        } catch (Throwable ex) {
+            if (Objects.isNull(listenerFailure)) {
+                throw ex;
             }
+            // keep the listener failure as the thrown exception
+            listenerFailure.addSuppressed(ex);
         }
     }
     
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/manager/impl/PullSwaggerDocServiceImpl.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/manager/impl/PullSwaggerDocServiceImpl.java
index bbdb392fa5..114f95e395 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/manager/impl/PullSwaggerDocServiceImpl.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/manager/impl/PullSwaggerDocServiceImpl.java
@@ -91,7 +91,6 @@ public class PullSwaggerDocServiceImpl implements 
PullSwaggerDocService {
         synchronized (interner.intern(instance.getClusterName())) {
             tagVO = saveTagVOAndAcquireLock(instance);
             if (!canPull(instance, tagVO)) {
-                LOG.info("api document has been pulled and cannot be pulled 
again,instance: {}", instance.getClusterName());
                 return;
             }
         }
@@ -127,16 +126,23 @@ public class PullSwaggerDocServiceImpl implements 
PullSwaggerDocService {
     }
 
     private boolean canPull(final UpstreamInstance instance, final TagVO 
tagVO) {
-        boolean canPull = false;
-        if (Objects.isNull(tagVO) || Objects.isNull(tagVO.getTagExt()) || 
StringUtils.isEmpty(tagVO.getTagExt().getDocLock())) {
-            LOG.info("Unable to obtain lock for {}, retry after {} seconds.", 
instance.getClusterName(), DOC_LOCK_EXPIRED_TIME / 1000);
+        if (Objects.isNull(tagVO) || Objects.isNull(tagVO.getTagExt())) {
+            return false;
+        }
+        if (!isRefreshDue(instance, tagVO.getTagExt())) {
+            LOG.info("api document has been pulled and cannot be pulled 
again,instance: {}", instance.getClusterName());
             return false;
         }
-        Long cacheLastStartUpTime = tagVO.getTagExt().getRefreshTime();
-        if (Objects.isNull(cacheLastStartUpTime) || instance.getStartupTime() 
> cacheLastStartUpTime + PULL_MIN_INTERVAL_TIME) {
-            canPull = true;
+        if (StringUtils.isEmpty(tagVO.getTagExt().getDocLock())) {
+            LOG.info("Unable to obtain lock for {}, retry after {} seconds.", 
instance.getClusterName(), DOC_LOCK_EXPIRED_TIME / 1000);
+            return false;
         }
-        return canPull;
+        return true;
+    }
+
+    private boolean isRefreshDue(final UpstreamInstance instance, final 
TagDO.TagExt tagExt) {
+        Long lastRefreshTime = tagExt.getRefreshTime();
+        return Objects.isNull(lastRefreshTime) || instance.getStartupTime() > 
lastRefreshTime + PULL_MIN_INTERVAL_TIME;
     }
 
     private TagVO saveTagVOAndAcquireLock(final UpstreamInstance instance) {
@@ -145,6 +151,11 @@ public class PullSwaggerDocServiceImpl implements 
PullSwaggerDocService {
             TagVO tagVO = tagVOList.get(0);
             TagDO.TagExt tagExt = convertTagExt(tagVO.getExt());
             tagVO.setTagExt(tagExt);
+            // already refreshed: skip without taking the lock, so nothing has 
to be written back
+            if (!isRefreshDue(instance, tagExt)) {
+                tagExt.setDocLock(null);
+                return tagVO;
+            }
             if (StringUtils.isNotEmpty(tagExt.getDocLock()) && 
NumberUtils.toLong(tagExt.getDocLock(), 0) > System.currentTimeMillis()) {
                 tagExt.setDocLock(null);
                 return tagVO;
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcherTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcherTest.java
index ba38da6df9..06a310413f 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcherTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/DataChangedEventDispatcherTest.java
@@ -28,6 +28,9 @@ import org.apache.shenyu.common.enums.ConfigGroupEnum;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
@@ -43,15 +46,19 @@ import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Stream;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
-import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -285,7 +292,7 @@ public final class DataChangedEventDispatcherTest {
     }
 
     /**
-     * onApplicationEvent DISCOVER_UPSTREAM configGroupEnum test case — also 
triggers loadDocOnUpstreamChanged.
+     * All discovery listeners receive the event, but documentation is loaded 
only once.
      */
     @Test
     void onApplicationEventWithDiscoverUpstreamConfigGroupTest() {
@@ -297,7 +304,118 @@ public final class DataChangedEventDispatcherTest {
         verify(nacosDataChangedListener, 
times(1)).onDiscoveryUpstreamChanged(anyList(), any());
         verify(websocketDataChangedListener, 
times(1)).onDiscoveryUpstreamChanged(anyList(), any());
         verify(zookeeperDataChangedListener, 
times(1)).onDiscoveryUpstreamChanged(anyList(), any());
-        verify(loadServiceDocEntry, 
atLeastOnce()).loadDocOnUpstreamChanged(anyList(), any());
+        verify(loadServiceDocEntry, 
times(1)).loadDocOnUpstreamChanged(anyList(), any());
+    }
+
+    /**
+     * Documentation is still loaded once when a later listener fails, and the 
listener failure is rethrown.
+     */
+    @Test
+    void onApplicationEventDiscoverUpstreamListenerFailureStillLoadsDocTest() {
+        when(clusterProperties.isEnabled()).thenReturn(false);
+        
when(applicationContext.getBeansOfType(DataChangedListener.class)).thenReturn(orderedListeners());
+        dataChangedEventDispatcher.afterPropertiesSet();
+        IllegalStateException listenerFailure = new 
IllegalStateException("listener failed");
+        
doThrow(listenerFailure).when(websocketDataChangedListener).onDiscoveryUpstreamChanged(anyList(),
 any());
+        DataChangedEvent dataChangedEvent = new 
DataChangedEvent(ConfigGroupEnum.DISCOVER_UPSTREAM, null, new ArrayList<>());
+
+        IllegalStateException thrown = 
assertThrows(IllegalStateException.class,
+                () -> 
dataChangedEventDispatcher.onApplicationEvent(dataChangedEvent));
+
+        assertSame(listenerFailure, thrown);
+        verify(nacosDataChangedListener, 
times(1)).onDiscoveryUpstreamChanged(anyList(), any());
+        verify(loadServiceDocEntry, 
times(1)).loadDocOnUpstreamChanged(anyList(), any());
+    }
+
+    /**
+     * A documentation failure does not replace the listener failure; it is 
attached as suppressed.
+     */
+    @ParameterizedTest
+    @MethodSource("listenerAndDocFailures")
+    void 
onApplicationEventDiscoverUpstreamListenerAndDocFailureKeepsListenerFailureTest(final
 Throwable listenerFailure, final Throwable docFailure) {
+        when(clusterProperties.isEnabled()).thenReturn(false);
+        
when(applicationContext.getBeansOfType(DataChangedListener.class)).thenReturn(orderedListeners());
+        dataChangedEventDispatcher.afterPropertiesSet();
+        
doThrow(listenerFailure).when(websocketDataChangedListener).onDiscoveryUpstreamChanged(anyList(),
 any());
+        
doThrow(docFailure).when(loadServiceDocEntry).loadDocOnUpstreamChanged(anyList(),
 any());
+        DataChangedEvent dataChangedEvent = new 
DataChangedEvent(ConfigGroupEnum.DISCOVER_UPSTREAM, null, new ArrayList<>());
+
+        Throwable thrown = assertThrows(listenerFailure.getClass(),
+                () -> 
dataChangedEventDispatcher.onApplicationEvent(dataChangedEvent));
+
+        assertSame(listenerFailure, thrown);
+        assertArrayEquals(new Throwable[]{docFailure}, thrown.getSuppressed());
+        verify(loadServiceDocEntry, 
times(1)).loadDocOnUpstreamChanged(anyList(), any());
+    }
+
+    /**
+     * Without a listener failure, a documentation failure is propagated as 
before.
+     */
+    @ParameterizedTest
+    @MethodSource("docFailures")
+    void onApplicationEventDiscoverUpstreamDocFailurePropagatesTest(final 
Throwable docFailure) {
+        when(clusterProperties.isEnabled()).thenReturn(false);
+        
doThrow(docFailure).when(loadServiceDocEntry).loadDocOnUpstreamChanged(anyList(),
 any());
+        DataChangedEvent dataChangedEvent = new 
DataChangedEvent(ConfigGroupEnum.DISCOVER_UPSTREAM, null, new ArrayList<>());
+
+        Throwable thrown = assertThrows(docFailure.getClass(),
+                () -> 
dataChangedEventDispatcher.onApplicationEvent(dataChangedEvent));
+
+        assertSame(docFailure, thrown);
+        verify(zookeeperDataChangedListener, 
times(1)).onDiscoveryUpstreamChanged(anyList(), any());
+    }
+
+    private static Stream<Arguments> listenerAndDocFailures() {
+        return Stream.of(
+                Arguments.of(new IllegalStateException("listener failed"), new 
IllegalArgumentException("doc failed")),
+                Arguments.of(new LinkageError("listener failed"), new 
IllegalArgumentException("doc failed")),
+                Arguments.of(new IllegalStateException("listener failed"), new 
AssertionError("doc failed")),
+                Arguments.of(new LinkageError("listener failed"), new 
AssertionError("doc failed")));
+    }
+
+    private static Stream<Throwable> docFailures() {
+        return Stream.of(new IllegalArgumentException("doc failed"), new 
AssertionError("doc failed"));
+    }
+
+    private Map<String, DataChangedListener> orderedListeners() {
+        Map<String, DataChangedListener> listenerMap = new LinkedHashMap<>();
+        listenerMap.put("nacosDataChangedListener", nacosDataChangedListener);
+        listenerMap.put("websocketDataChangedListener", 
websocketDataChangedListener);
+        listenerMap.put("zookeeperDataChangedListener", 
zookeeperDataChangedListener);
+        return listenerMap;
+    }
+
+    /**
+     * Discovery events do not load documentation when no listeners are 
registered.
+     */
+    @Test
+    void onApplicationEventWithoutListenersSkipsDocLoadingTest() {
+        when(clusterProperties.isEnabled()).thenReturn(false);
+        
when(applicationContext.getBeansOfType(DataChangedListener.class)).thenReturn(Collections.emptyMap());
+        dataChangedEventDispatcher.afterPropertiesSet();
+        DataChangedEvent dataChangedEvent = new 
DataChangedEvent(ConfigGroupEnum.DISCOVER_UPSTREAM, null, new ArrayList<>());
+
+        dataChangedEventDispatcher.onApplicationEvent(dataChangedEvent);
+
+        verify(loadServiceDocEntry, 
never()).loadDocOnUpstreamChanged(anyList(), any());
+    }
+
+    /**
+     * Discovery events do not load documentation when all listeners are 
skipped on a follower.
+     */
+    @Test
+    void onApplicationEventNotMasterWithoutCacheListenersSkipsDocLoadingTest() 
{
+        when(clusterProperties.isEnabled()).thenReturn(true);
+        when(shenyuClusterSelectMasterService.isMaster()).thenReturn(false);
+        when(applicationContext.getBeansOfType(DataChangedListener.class))
+                
.thenReturn(Collections.singletonMap("nacosDataChangedListener", 
nacosDataChangedListener));
+        dataChangedEventDispatcher.afterPropertiesSet();
+        DataChangedEvent dataChangedEvent = new 
DataChangedEvent(ConfigGroupEnum.DISCOVER_UPSTREAM, null, new ArrayList<>());
+
+        dataChangedEventDispatcher.onApplicationEvent(dataChangedEvent);
+
+        verify(nacosDataChangedListener, 
never()).onDiscoveryUpstreamChanged(anyList(), any());
+        verify(loadServiceDocEntry, 
never()).loadDocOnUpstreamChanged(anyList(), any());
     }
 
     /**
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/manager/impl/SwaggerDocManagerImplTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/manager/impl/SwaggerDocManagerImplTest.java
index aa247083b4..b9225faeb4 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/manager/impl/SwaggerDocManagerImplTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/manager/impl/SwaggerDocManagerImplTest.java
@@ -17,7 +17,15 @@
 
 package org.apache.shenyu.admin.service.manager.impl;
 
+import com.sun.net.httpserver.HttpServer;
+import org.apache.http.HttpStatus;
 import org.apache.shenyu.admin.model.bean.UpstreamInstance;
+import org.apache.shenyu.admin.model.entity.TagDO;
+import org.apache.shenyu.admin.model.vo.TagVO;
+import org.apache.shenyu.admin.service.TagService;
+import org.apache.shenyu.admin.service.manager.DocManager;
+import org.apache.shenyu.common.constant.AdminConstants;
+import org.apache.shenyu.common.utils.GsonUtils;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
@@ -26,10 +34,21 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.Collections;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
@@ -41,10 +60,103 @@ public class SwaggerDocManagerImplTest {
     @Mock
     private Set<UpstreamInstance> currentServices;
 
+    @Mock
+    private TagService tagService;
+
+    @Mock
+    private DocManager docManager;
+
     @Test
     public void testPullApiDocument() {
         serviceDocManager.pullApiDocument(currentServices);
         verify(currentServices).forEach(any());
     }
 
+    @Test
+    public void testPullApiDocumentSkipsLockWhenAlreadyRefreshed() {
+        UpstreamInstance instance = new UpstreamInstance();
+        instance.setContextPath("/test");
+        instance.setStartupTime(1000L);
+        TagDO.TagExt tagExt = new TagDO.TagExt();
+        tagExt.setRefreshTime(1000L);
+        tagExt.setApiDocMd5("existing-md5");
+        TagVO tagVO = new TagVO();
+        tagVO.setId("tag-id");
+        tagVO.setExt(GsonUtils.getInstance().toJson(tagExt));
+        when(tagService.findByQuery(instance.getContextPath(), 
AdminConstants.TAG_ROOT_PARENT_ID))
+                .thenReturn(Collections.singletonList(tagVO));
+
+        for (int i = 0; i < 100; i++) {
+            serviceDocManager.pullApiDocument(instance);
+        }
+
+        verify(tagService, never()).updateTagExt(any(), any());
+        verifyNoInteractions(docManager);
+    }
+
+    @Test
+    public void testPullApiDocumentReleasesExpiredLockAfterHttpFailure() 
throws IOException {
+        UpstreamInstance instance = new UpstreamInstance();
+        instance.setContextPath("/test");
+        instance.setIp("127.0.0.1");
+        instance.setStartupTime(31001L);
+        TagDO.TagExt tagExt = new TagDO.TagExt();
+        tagExt.setRefreshTime(1000L);
+        tagExt.setDocLock("1");
+        TagVO tagVO = new TagVO();
+        tagVO.setId("tag-id");
+        tagVO.setExt(GsonUtils.getInstance().toJson(tagExt));
+        when(tagService.findByQuery(instance.getContextPath(), 
AdminConstants.TAG_ROOT_PARENT_ID))
+                .thenReturn(Collections.singletonList(tagVO));
+        when(tagService.updateTagExt(eq(tagVO.getId()), 
any(TagDO.TagExt.class))).thenAnswer(invocation -> {
+            TagDO.TagExt updatedTagExt = invocation.getArgument(1);
+            // Serialize each write to distinguish persisted changes from 
in-memory mutations.
+            tagVO.setExt(GsonUtils.getInstance().toJson(updatedTagExt));
+            return 1;
+        });
+
+        AtomicInteger requests = new AtomicInteger();
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        try {
+            server.createContext("/test/v3/api-docs", exchange -> {
+                requests.incrementAndGet();
+                
exchange.sendResponseHeaders(HttpStatus.SC_SERVICE_UNAVAILABLE, -1);
+                exchange.close();
+            });
+            server.start();
+            instance.setPort(server.getAddress().getPort());
+
+            serviceDocManager.pullApiDocument(instance);
+
+            assertEquals(1, requests.get());
+            verify(tagService, times(2)).updateTagExt(eq(tagVO.getId()), 
any(TagDO.TagExt.class));
+            TagDO.TagExt persistedTagExt = 
GsonUtils.getInstance().fromJson(tagVO.getExt(), TagDO.TagExt.class);
+            assertNull(persistedTagExt.getDocLock());
+            assertEquals(tagExt.getRefreshTime(), 
persistedTagExt.getRefreshTime());
+            verifyNoInteractions(docManager);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void testPullApiDocumentDoesNotReleaseLockHeldByAnotherPull() {
+        UpstreamInstance instance = new UpstreamInstance();
+        instance.setContextPath("/test");
+        instance.setStartupTime(31001L);
+        TagDO.TagExt tagExt = new TagDO.TagExt();
+        tagExt.setRefreshTime(1000L);
+        tagExt.setDocLock(String.valueOf(Long.MAX_VALUE));
+        TagVO tagVO = new TagVO();
+        tagVO.setId("tag-id");
+        tagVO.setExt(GsonUtils.getInstance().toJson(tagExt));
+        when(tagService.findByQuery(instance.getContextPath(), 
AdminConstants.TAG_ROOT_PARENT_ID))
+                .thenReturn(Collections.singletonList(tagVO));
+
+        serviceDocManager.pullApiDocument(instance);
+
+        verify(tagService, never()).updateTagExt(any(), any());
+        verifyNoInteractions(docManager);
+    }
+
 }

Reply via email to