Repository navigation
fix: Restarted the watch after stream reset #19055 #19056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b31a681
3df0b36
bc2f2d9
8635609
5b28bc3
cbcc766
c66ddf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -254,50 +254,51 @@ private void watch() | |
| 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; | ||
|
vivek807 marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Persistent resets can cause a tight relist loop Returning from this handler immediately re-enters the outer |
||
| } | ||
|
vivek807 marked this conversation as resolved.
|
||
| catch (SocketTimeoutException ex) { | ||
| // socket read timeout can happen normally due to k8s not having anything new to push leading to socket | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BOOKMARK events always has resource version