This is an automated email from the ASF dual-hosted git repository.

FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new eb43fd0458d fix: Restarted the watch after stream reset #19055 (#19056)
eb43fd0458d is described below

commit eb43fd0458d0f4d3da8cfdebecfcd366d9f45be1
Author: Vivek Dhiman <[email protected]>
AuthorDate: Thu Oct 8 18:28:02 2026 +0530

    fix: Restarted the watch after stream reset #19055 (#19056)
    
    * Restarted the watch after stream reset
    
    * Addressed review comments
    
    * Add unit test and addresses styling issues
    
    * Fix service discovery bug in `kubernetes-extensions` (#19139)
    
    * Address review comments.
    Remove hard dependency of okhttp3 internals
    
    * fix: remove duplicate JUnit4 tests reintroduced by rebase
    
    Rebasing onto apache/druid master silently duplicated three
    childRemoved(skipIfUnknown) tests: the JUnit5 versions already in this
    branch's history merged alongside stale JUnit4 copies from before the
    junit5-migration commit, instead of conflicting.
    
    * Handle Bookmark event
    Address review comments
    
    Signed-off-by: Vivek Dhiman <[email protected]>
    
    ---------
    
    Signed-off-by: Vivek Dhiman <[email protected]>
    Co-authored-by: Lucas Capistrant <[email protected]>
---
 extensions-core/kubernetes-extensions/pom.xml      |   6 +
 .../druid/k8s/discovery/ChannelResetException.java |  30 ++++
 .../druid/k8s/discovery/DefaultK8sApiClient.java   |  19 ++-
 .../discovery/K8sDruidNodeDiscoveryProvider.java   |  71 ++++-----
 .../apache/druid/k8s/discovery/WatchResult.java    |   8 +-
 .../DefaultK8sApiClientWatchPodsTest.java          | 164 +++++++++++++++++++++
 .../K8sDruidNodeDiscoveryProviderTest.java         | 135 ++++++++++++++++-
 7 files changed, 387 insertions(+), 46 deletions(-)

diff --git a/extensions-core/kubernetes-extensions/pom.xml 
b/extensions-core/kubernetes-extensions/pom.xml
index 7decd1a881f..b1340affb03 100644
--- a/extensions-core/kubernetes-extensions/pom.xml
+++ b/extensions-core/kubernetes-extensions/pom.xml
@@ -111,6 +111,12 @@
       <artifactId>easymock</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp3</groupId>
+      <artifactId>mockwebserver3</artifactId>
+      <version>${okhttp.version}</version>
+      <scope>test</scope>
+    </dependency>
 
     <!-- others -->
     <dependency>
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/ChannelResetException.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/ChannelResetException.java
new file mode 100644
index 00000000000..345ec04fed9
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/ChannelResetException.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.k8s.discovery;
+
+import java.io.IOException;
+
+public class ChannelResetException extends IOException
+{
+  public ChannelResetException(Throwable ex)
+  {
+    super(ex);
+  }
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/DefaultK8sApiClient.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/DefaultK8sApiClient.java
index b7992a3fb2a..95c8dfd4154 100644
--- 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/DefaultK8sApiClient.java
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/DefaultK8sApiClient.java
@@ -40,6 +40,7 @@ import org.apache.druid.java.util.common.RE;
 import org.apache.druid.java.util.common.logger.Logger;
 
 import java.io.IOException;
+import java.io.InterruptedIOException;
 import java.net.SocketTimeoutException;
 import java.util.HashMap;
 import java.util.List;
@@ -194,7 +195,7 @@ public class DefaultK8sApiClient implements K8sApiClient
         private Watch.Response<DiscoveryDruidNodeAndResourceVersion> obj;
 
         @Override
-        public boolean hasNext() throws SocketTimeoutException
+        public boolean hasNext() throws IOException
         {
           try {
             while (watch.hasNext()) {
@@ -263,8 +264,14 @@ public class DefaultK8sApiClient implements K8sApiClient
                 return true;
               } else if (item != null && item.type != null && 
item.type.equals(WatchResult.BOOKMARK)) {
                 // Events with type BOOKMARK will only contain resourceVersion 
and no metadata. See
-                // Kubernetes API documentation for details.
-                LOGGER.debug("BOOKMARK event fired, no nothing, only update 
resourceVersion");
+                // Kubernetes API documentation for details. Build a response 
carrying just the
+                //updated resourceVersion; otherwise obj would still hold the 
previous event (or
+                // null on the first event), corrupting the caller's 
resourceVersion tracking.
+                LOGGER.debug("BOOKMARK event fired, only updating 
resourceVersion");
+                obj = new Watch.Response<>(
+                        WatchResult.BOOKMARK,
+                        new DiscoveryDruidNodeAndResourceVersion(
+                                
item.object.getMetadata().getResourceVersion(), null));
                 return true;
               } else {
                 LOGGER.error("WTH! item or item.type is NULL");
@@ -274,9 +281,11 @@ public class DefaultK8sApiClient implements K8sApiClient
           catch (RuntimeException ex) {
             if (ex.getCause() instanceof SocketTimeoutException) {
               throw (SocketTimeoutException) ex.getCause();
-            } else {
-              throw ex;
             }
+            if (ex.getCause() instanceof IOException && !(ex.getCause() 
instanceof InterruptedIOException)) {
+              throw new ChannelResetException(ex.getCause());
+            }
+            throw ex;
           }
 
           return false;
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProvider.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProvider.java
index 8dfc595db74..522b7aa4241 100644
--- 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProvider.java
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProvider.java
@@ -254,50 +254,51 @@ public class K8sDruidNodeDiscoveryProvider extends 
DruidNodeDiscoveryProvider
     private void keepWatching(String labelSelector, String resourceVersion)
     {
       String nextResourceVersion = resourceVersion;
-      while (lifecycleLock.isStarted()) {
-        try {
-          WatchResult iter =
-              k8sApiClient.watchPods(podInfo.getPodNamespace(), labelSelector, 
nextResourceVersion, nodeRole);
 
+      while (lifecycleLock.isStarted()) {
+        try (WatchResult iter = 
k8sApiClient.watchPods(podInfo.getPodNamespace(), labelSelector, 
nextResourceVersion, nodeRole)) {
           if (iter == null) {
             // history not available, we need to start from scratch
             return;
           }
 
-          try {
-            while (iter.hasNext()) {
-              Watch.Response<DiscoveryDruidNodeAndResourceVersion> item = 
iter.next();
-              if (item != null && item.type != null && item.object != null) {
-                switch (item.type) {
-                  case WatchResult.ADDED:
-                    baseNodeRoleWatcher.childAdded(item.object.getNode());
-                    break;
-                  case WatchResult.DELETED:
-                  case WatchResult.NOT_READY:
-                    // Use skipIfUnknown=true for all k8s discovery removals.
-                    // DELETED can fire after NOT_READY (so the service is 
already removed), or before ADDED (pod deleted before becoming ready).
-                    // NOT_READY can repeat during CrashLoopBackOff. None of 
these warrant the error-level logging that
-                    // comes with trying to remove an unknown service.
-                    baseNodeRoleWatcher.childRemoved(item.object.getNode(), 
true);
-                    break;
-                  default:
-                }
-
-                // This should be updated after the action has been dealt with 
successfully
-                nextResourceVersion = item.object.getResourceVersion();
-
-              } else {
-                // Try again by starting the watch from the beginning. This 
can happen if the
-                // watch goes bad.
-                LOGGER.debug("Received NULL item while watching role[%s]. 
Restarting watch.", this.nodeRole);
-                return;
-              }
+          while (iter.hasNext()) {
+            Watch.Response<DiscoveryDruidNodeAndResourceVersion> item = 
iter.next();
+
+            if (item == null || item.type == null || item.object == null) {
+              LOGGER.debug("Received NULL item while watching role[%s]. 
Restarting watch.", this.nodeRole);
+              return;
             }
-          }
-          finally {
-            iter.close();
+
+            switch (item.type) {
+              case WatchResult.ADDED:
+                baseNodeRoleWatcher.childAdded(item.object.getNode());
+                break;
+              case WatchResult.DELETED:
+              case WatchResult.NOT_READY:
+                // Use skipIfUnknown=true for all k8s discovery removals.
+                // DELETED can fire after NOT_READY (so the service is already 
removed), or before ADDED (pod deleted before becoming ready).
+                // NOT_READY can repeat during CrashLoopBackOff. None of these 
warrant the error-level logging that
+                // comes with trying to remove an unknown service.
+                baseNodeRoleWatcher.childRemoved(item.object.getNode(), true);
+                break;
+              default:
+            }
+
+            // This should be updated after the action has been dealt with 
successfully
+            nextResourceVersion = item.object.getResourceVersion();
           }
 
+          LOGGER.trace("Watch closed normally for role[%s]", this.nodeRole);
+          return;
+        }
+        catch (ChannelResetException ex) {
+          LOGGER.debug("Watch stream terminated normally for role[%s], 
restarting", this.nodeRole);
+          // Apply the same backoff as other retry paths below: a persistently 
reset stream
+          // (flaky proxy, LB idle timeout shorter than expected) would 
otherwise cause an
+          // unbounded relist/watch loop against the API server.
+          sleep(watcherErrorRetryWaitMS);
+          return;
         }
         catch (SocketTimeoutException ex) {
           // socket read timeout can happen normally due to k8s not having 
anything new to push leading to socket
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/WatchResult.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/WatchResult.java
index 2b3ef7b584a..37cac3768c5 100644
--- 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/WatchResult.java
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/WatchResult.java
@@ -21,7 +21,7 @@ package org.apache.druid.k8s.discovery;
 
 import io.kubernetes.client.util.Watch;
 
-import java.net.SocketTimeoutException;
+import java.io.IOException;
 
 /**
  * Iterator over k8s pod watch events that is aligned with the needs of Druid 
service discovery rather than
@@ -29,7 +29,7 @@ import java.net.SocketTimeoutException;
  * a Kubernetes MODIFIED event for a pod whose containers are no longer ready 
(according to k8s readiness state) is surfaced as the
  * synthetic {@link #NOT_READY} type so that consumers can handle it as a 
removal from k8s service discovery.
  */
-public interface WatchResult
+public interface WatchResult extends AutoCloseable
 {
   String ADDED = "ADDED";
   String MODIFIED = "MODIFIED";
@@ -44,9 +44,7 @@ public interface WatchResult
    */
   String NOT_READY = "NOT_READY";
 
-  boolean hasNext() throws SocketTimeoutException;
+  boolean hasNext() throws IOException;
 
   Watch.Response<DiscoveryDruidNodeAndResourceVersion> next();
-
-  void close();
 }
diff --git 
a/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/DefaultK8sApiClientWatchPodsTest.java
 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/DefaultK8sApiClientWatchPodsTest.java
new file mode 100644
index 00000000000..33a73d972e3
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/DefaultK8sApiClientWatchPodsTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.k8s.discovery;
+
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.util.Watch;
+import mockwebserver3.MockResponse;
+import mockwebserver3.MockWebServer;
+import mockwebserver3.SocketEffect;
+import okhttp3.Headers;
+import org.apache.druid.discovery.DiscoveryDruidNode;
+import org.apache.druid.discovery.NodeRole;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.server.DruidNode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+/**
+ * Exercises {@link DefaultK8sApiClient#watchPods} end to end against a real 
(but local, canned)
+ * HTTP watch stream: BOOKMARK events must surface their resourceVersion to 
the caller instead of
+ * a stale or null response, and a mid-stream connection reset must surface as
+ * {@link ChannelResetException} rather than an unrelated exception type.
+ */
+public class DefaultK8sApiClientWatchPodsTest
+{
+  private static final ObjectMapper JSON_MAPPER = new DefaultObjectMapper();
+
+  static {
+    // DiscoveryDruidNode's @JsonCreator takes a @JacksonInject ObjectMapper, 
so the mapper
+    // used to deserialize it must know how to inject itself.
+    JSON_MAPPER.setInjectableValues(new 
InjectableValues.Std().addValue(ObjectMapper.class, JSON_MAPPER));
+  }
+
+  private final DiscoveryDruidNode testNode = new DiscoveryDruidNode(
+      new DruidNode("druid/router", "test-host", true, 80, null, true, false), 
NodeRole.ROUTER, null);
+
+  private MockWebServer server;
+  private DefaultK8sApiClient client;
+
+  @BeforeEach
+  public void setUp() throws IOException
+  {
+    server = new MockWebServer();
+    server.start();
+
+    ApiClient apiClient = new 
ApiClient().setBasePath(server.url("/").toString());
+    client = new DefaultK8sApiClient(apiClient, JSON_MAPPER);
+  }
+
+  @AfterEach
+  public void tearDown() throws IOException
+  {
+    server.close();
+  }
+
+  @Test
+  public void testBookmarkAsFirstEventSurfacesResourceVersion() throws 
Exception
+  {
+    enqueueWatchStream(bookmarkLine("100"));
+
+    try (WatchResult result = client.watchPods("testns", null, "99", 
NodeRole.ROUTER)) {
+      Assertions.assertTrue(result.hasNext());
+      Watch.Response<DiscoveryDruidNodeAndResourceVersion> response = 
result.next();
+
+      Assertions.assertNotNull(response, "BOOKMARK event must not surface as a 
null response");
+      Assertions.assertEquals(WatchResult.BOOKMARK, response.type);
+      Assertions.assertNotNull(response.object);
+      Assertions.assertEquals("100", response.object.getResourceVersion());
+    }
+  }
+
+  @Test
+  public void testBookmarkAfterRealEventSurfacesItsOwnResourceVersion() throws 
Exception
+  {
+    enqueueWatchStream(addedLine("200", testNode) + bookmarkLine("201"));
+
+    try (WatchResult result = client.watchPods("testns", null, "99", 
NodeRole.ROUTER)) {
+      Assertions.assertTrue(result.hasNext());
+      Watch.Response<DiscoveryDruidNodeAndResourceVersion> added = 
result.next();
+      Assertions.assertEquals(WatchResult.ADDED, added.type);
+      Assertions.assertEquals("200", added.object.getResourceVersion());
+
+      Assertions.assertTrue(result.hasNext());
+      Watch.Response<DiscoveryDruidNodeAndResourceVersion> bookmark = 
result.next();
+
+      Assertions.assertNotNull(bookmark, "BOOKMARK event must not surface as a 
null response");
+      Assertions.assertEquals(WatchResult.BOOKMARK, bookmark.type);
+      Assertions.assertNotNull(bookmark.object);
+      Assertions.assertEquals(
+          "201",
+          bookmark.object.getResourceVersion(),
+          "BOOKMARK must carry its own resourceVersion, not the preceding 
event's");
+    }
+  }
+
+  @Test
+  public void testConnectionResetMidStreamSurfacesAsChannelResetException() 
throws Exception
+  {
+    // Truncate the body mid-object and sever the connection while it's being 
read, simulating
+    // a server-side stream reset. okhttp surfaces this as a 
ProtocolException, which
+    // DefaultK8sApiClient must remap to ChannelResetException rather than an 
unrelated
+    // RuntimeException, so callers can distinguish it from other IO failures.
+    server.enqueue(new MockResponse.Builder()
+        .code(200)
+        .headers(new Headers.Builder()
+            .add("Content-Type", "application/json")
+            .build())
+        .body(addedLine("200", testNode).substring(0, 20))
+        .onResponseBody(new SocketEffect.CloseSocket(true, true, true))
+        .build());
+
+    try (WatchResult result = client.watchPods("testns", null, "99", 
NodeRole.ROUTER)) {
+      Assertions.assertThrows(ChannelResetException.class, result::hasNext);
+    }
+  }
+
+  private void enqueueWatchStream(String body)
+  {
+    server.enqueue(new MockResponse(
+        200,
+        new Headers.Builder().add("Content-Type", "application/json").build(),
+        body));
+  }
+
+  private static String bookmarkLine(String resourceVersion)
+  {
+    return 
"{\"type\":\"BOOKMARK\",\"object\":{\"kind\":\"Pod\",\"apiVersion\":\"v1\",\"metadata\":{\"resourceVersion\":\""
+        + resourceVersion + "\"}}}\n";
+  }
+
+  private static String addedLine(String resourceVersion, DiscoveryDruidNode 
node) throws Exception
+  {
+    String infoAnnotation = 
K8sDruidNodeAnnouncer.getInfoAnnotation(node.getNodeRole());
+    String nodeJson = 
StringUtils.replace(JSON_MAPPER.writeValueAsString(node), "\"", "\\\"");
+    return 
"{\"type\":\"ADDED\",\"object\":{\"kind\":\"Pod\",\"apiVersion\":\"v1\",\"metadata\":{"
+        + "\"name\":\"test-pod\",\"resourceVersion\":\"" + resourceVersion + 
"\","
+        + "\"annotations\":{\"" + infoAnnotation + "\":\"" + nodeJson + "\"}},"
+        + 
"\"status\":{\"containerStatuses\":[{\"name\":\"main\",\"ready\":true}]}}}\n";
+  }
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProviderTest.java
 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProviderTest.java
index 6417edb4b27..445247575fa 100644
--- 
a/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProviderTest.java
+++ 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/discovery/K8sDruidNodeDiscoveryProviderTest.java
@@ -34,6 +34,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
 
+import java.io.IOException;
 import java.net.SocketTimeoutException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -486,6 +487,122 @@ public class K8sDruidNodeDiscoveryProviderTest
     );
   }
 
+  @Test
+  @Timeout(value = 10_000, unit = TimeUnit.MILLISECONDS)
+  public void testNodeRoleWatcherHandlesChannelResetException() throws 
Exception
+  {
+    String labelSelector = 
"druidDiscoveryAnnouncement-cluster-identifier=druid-cluster,druidDiscoveryAnnouncement-router=true";
+    K8sApiClient mockK8sApiClient = EasyMock.createMock(K8sApiClient.class);
+
+    EasyMock.expect(mockK8sApiClient.listPods(podInfo.getPodNamespace(), 
labelSelector, NodeRole.ROUTER)).andReturn(
+        new DiscoveryDruidNodeList(
+          "v1",
+          ImmutableMap.of(
+            testNode1.getDruidNode().getHostAndPortToUse(), testNode1,
+            testNode2.getDruidNode().getHostAndPortToUse(), testNode2
+          )
+        )
+    );
+
+    EasyMock.expect(mockK8sApiClient.watchPods(
+        podInfo.getPodNamespace(), labelSelector, "v1", 
NodeRole.ROUTER)).andReturn(
+        new MockWatchResult(Collections.emptyList(), false, false, true)
+    );
+
+    EasyMock.expect(mockK8sApiClient.listPods(podInfo.getPodNamespace(), 
labelSelector, NodeRole.ROUTER)).andReturn(
+        new DiscoveryDruidNodeList(
+          "v2",
+          ImmutableMap.of(
+            testNode2.getDruidNode().getHostAndPortToUse(), testNode2,
+            testNode3.getDruidNode().getHostAndPortToUse(), testNode3
+          )
+        )
+    );
+
+    EasyMock.expect(mockK8sApiClient.watchPods(
+        podInfo.getPodNamespace(), labelSelector, "v2", 
NodeRole.ROUTER)).andReturn(
+        new MockWatchResult(Collections.emptyList(), false, false)
+    );
+
+    EasyMock.replay(mockK8sApiClient);
+
+    K8sDruidNodeDiscoveryProvider discoveryProvider = new 
K8sDruidNodeDiscoveryProvider(
+        podInfo,
+        discoveryConfig,
+        mockK8sApiClient,
+        1
+    );
+    discoveryProvider.start();
+
+    K8sDruidNodeDiscoveryProvider.NodeRoleWatcher nodeDiscovery =
+        discoveryProvider.getForNodeRole(NodeRole.ROUTER, false);
+
+    MockListener testListener = new MockListener(
+        ImmutableList.of(
+          MockListener.Event.added(testNode1),
+          MockListener.Event.added(testNode2),
+          MockListener.Event.inited(),
+          MockListener.Event.added(testNode3),
+          MockListener.Event.deleted(testNode1)
+        )
+    );
+    nodeDiscovery.registerListener(testListener);
+    nodeDiscovery.start();
+    testListener.assertSuccess();
+    discoveryProvider.stop();
+  }
+
+  @Test
+  @Timeout(value = 10_000, unit = TimeUnit.MILLISECONDS)
+  public void testPersistentChannelResetAppliesBackoffBetweenRelists() throws 
Exception
+  {
+    String labelSelector =
+      
"druidDiscoveryAnnouncement-cluster-identifier=druid-cluster,druidDiscoveryAnnouncement-router=true";
+    K8sApiClient mockK8sApiClient = EasyMock.createMock(K8sApiClient.class);
+
+    // Three consecutive watch attempts are immediately reset, followed by a 
clean one. Without
+    // backoff on the ChannelResetException path, these three relist/watch 
cycles would complete
+    // almost instantly; with backoff, each reset costs 
watcherErrorRetryWaitMS.
+    for (int i = 0; i < 3; i++) {
+      EasyMock.expect(mockK8sApiClient.listPods(podInfo.getPodNamespace(), 
labelSelector, NodeRole.ROUTER))
+              .andReturn(new DiscoveryDruidNodeList("v1", ImmutableMap.of()));
+      EasyMock.expect(mockK8sApiClient.watchPods(podInfo.getPodNamespace(), 
labelSelector, "v1", NodeRole.ROUTER))
+              .andReturn(new MockWatchResult(Collections.emptyList(), false, 
false, true));
+    }
+
+    EasyMock.expect(mockK8sApiClient.listPods(podInfo.getPodNamespace(), 
labelSelector, NodeRole.ROUTER))
+            .andReturn(new DiscoveryDruidNodeList(
+              "v2", 
ImmutableMap.of(testNode1.getDruidNode().getHostAndPortToUse(), testNode1)));
+    EasyMock.expect(mockK8sApiClient.watchPods(podInfo.getPodNamespace(), 
labelSelector, "v2", NodeRole.ROUTER))
+            .andReturn(new MockWatchResult(Collections.emptyList(), false, 
false));
+
+    EasyMock.replay(mockK8sApiClient);
+
+    long watcherErrorRetryWaitMS = 1500;
+    K8sDruidNodeDiscoveryProvider discoveryProvider =
+      new K8sDruidNodeDiscoveryProvider(podInfo, discoveryConfig, 
mockK8sApiClient, watcherErrorRetryWaitMS);
+    discoveryProvider.start();
+
+    K8sDruidNodeDiscoveryProvider.NodeRoleWatcher nodeDiscovery =
+      discoveryProvider.getForNodeRole(NodeRole.ROUTER, false);
+
+    MockListener testListener =
+      new MockListener(ImmutableList.of(MockListener.Event.inited(), 
MockListener.Event.added(testNode1)));
+    nodeDiscovery.registerListener(testListener);
+
+    long start = System.currentTimeMillis();
+    nodeDiscovery.start();
+    testListener.assertSuccess();
+    long elapsed = System.currentTimeMillis() - start;
+
+    discoveryProvider.stop();
+
+    Assertions.assertTrue(
+      elapsed >= 3 * watcherErrorRetryWaitMS,
+      "Expected at least 3 backoff waits (" + (3 * watcherErrorRetryWaitMS) + 
"ms) for 3 persistent "
+      + "channel resets, but only " + elapsed + "ms elapsed");
+  }
+
   private static class MockListener implements DruidNodeDiscovery.Listener
   {
     List<Event> events;
@@ -654,6 +771,7 @@ public class K8sDruidNodeDiscoveryProviderTest
 
     private volatile boolean timeoutOnStart;
     private volatile boolean timeooutOnEmptyResults;
+    private volatile boolean channelResetOnStart;
     private volatile boolean closeCalled = false;
 
     public MockWatchResult(
@@ -667,13 +785,28 @@ public class K8sDruidNodeDiscoveryProviderTest
       this.timeooutOnEmptyResults = timeooutOnEmptyResults;
     }
 
+    public MockWatchResult(
+        List<Watch.Response<DiscoveryDruidNodeAndResourceVersion>> results,
+        boolean timeoutOnStart,
+        boolean timeooutOnEmptyResults,
+        boolean channelResetOnStart
+    )
+    {
+      this(results, timeoutOnStart, timeooutOnEmptyResults);
+      this.channelResetOnStart = channelResetOnStart;
+    }
+
     @Override
-    public boolean hasNext() throws SocketTimeoutException
+    public boolean hasNext() throws IOException
     {
       if (timeoutOnStart) {
         throw new SocketTimeoutException("testing timeout on start!!!");
       }
 
+      if (channelResetOnStart) {
+        throw new ChannelResetException(new RuntimeException("simulated 
StreamResetException"));
+      }
+
       if (results.isEmpty()) {
         if (timeooutOnEmptyResults) {
           throw new SocketTimeoutException("testing timeout on end!!!");


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to