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

abhishekrb19 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 cf75e06d4f9 fix: Prevent a single unhealthy historical from stalling 
all segment loading and balancing. (#19950)
cf75e06d4f9 is described below

commit cf75e06d4f9343fe750235f69abf7b15a81c97c8
Author: Abhishek Radhakrishnan <[email protected]>
AuthorDate: Tue Aug 25 09:34:45 2026 -0700

    fix: Prevent a single unhealthy historical from stalling all segment 
loading and balancing. (#19950)
    
    HttpLoadQueuePeon fetches a historical's segment-loading capabilities
    synchronously in its constructor, via GET 
/druid-internal/v1/segments/loadCapabilities.
    On any response other than 200 or 404 (e.g. a 503 from an overloaded or
    briefly-unavailable historical), the constructor threw an RE.
    
    Make the capabilities fetch degrade gracefully instead of throwing. On a 
non-OK
    response or any error, alert and fall back to default 
SegmentLoadingCapabilities
    (derived from the configured batch size) — mirroring the behavior the 404 
branch
    already had. The peon is still created, the server is still managed with
    conservative defaults, and the rest of the duty group proceeds. Operators 
still
    get one alert per affected server.
---
 .../coordinator/loading/HttpLoadQueuePeon.java     | 196 +++++++++++++---
 .../server/coordinator/DruidCoordinatorTest.java   | 210 +++++++++++++++++
 .../coordinator/loading/HttpLoadQueuePeonTest.java | 261 +++++++++++++++++++++
 3 files changed, 639 insertions(+), 28 deletions(-)

diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeon.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeon.java
index 37ebaa8d9a0..438bea3a7bd 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeon.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeon.java
@@ -57,7 +57,9 @@ import org.joda.time.Duration;
 import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletResponse;
 import javax.ws.rs.core.MediaType;
+import java.io.IOException;
 import java.io.InputStream;
+import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -127,7 +129,32 @@ public class HttpLoadQueuePeon implements LoadQueuePeon
   private final Supplier<SegmentLoadingMode> loadingModeSupplier;
 
   private final ObjectWriter requestBodyWriter;
-  private final SegmentLoadingCapabilities serverCapabilities;
+
+  /**
+   * Loading capabilities of the server. Fetched during construction and 
re-fetched
+   * lazily on subsequent ticks if the initial fetch fell back to default 
values due
+   * to a transient failure (see {@link #refetchCapabilitiesIfNeeded()}). Read 
and
+   * written only on the single-threaded processing executor after 
construction, but
+   * declared {@code volatile} for safe publication from the constructing 
thread.
+   */
+  private volatile SegmentLoadingCapabilities serverCapabilities;
+
+  /**
+   * Whether {@link #serverCapabilities} holds a definitive value: {@code 
true} once
+   * the server has returned real capabilities, or a 404 indicating the 
endpoint does
+   * not exist on this server. It stays {@code false} while the peon is pinned 
to
+   * default capabilities due to a transient failure, so the value is 
re-fetched once
+   * the server recovers.
+   */
+  private volatile boolean capabilitiesConfirmed = false;
+
+  /**
+   * Guards {@link #refetchCapabilitiesIfNeeded()} so that at most one 
capability probe is
+   * outstanding at a time. Without this, every {@link #doSegmentManagement()} 
tick issues its
+   * own probe whenever {@link #capabilitiesConfirmed} is false, so queuing 
many segments onto a
+   * still-unhealthy server fires one redundant concurrent probe per tick at 
that same server.
+   */
+  private final AtomicBoolean refetchInProgress = new AtomicBoolean(false);
 
   public HttpLoadQueuePeon(
       String baseUrl,
@@ -151,48 +178,160 @@ public class HttpLoadQueuePeon implements LoadQueuePeon
     this.serverCapabilities = fetchSegmentLoadingCapabilities();
   }
 
+  private URL getLoadCapabilitiesUrl() throws MalformedURLException
+  {
+    return new URL(new URL(serverId), 
"druid-internal/v1/segments/loadCapabilities");
+  }
+
+  /**
+   * Synchronously fetches loading capabilities during construction. On a 
transient failure
+   * (non-OK status other than 404, timeout, or error), raises an alert and 
falls back to
+   * default capabilities, leaving {@link #capabilitiesConfirmed} unset so the 
value is
+   * re-fetched on a later tick once the server recovers (see {@link 
#refetchCapabilitiesIfNeeded()}).
+   */
   private SegmentLoadingCapabilities fetchSegmentLoadingCapabilities()
   {
     try {
-      final URL segmentLoadingCapabilitiesURL = new URL(
-          new URL(serverId),
-          "druid-internal/v1/segments/loadCapabilities"
-      );
-
-      BytesAccumulatingResponseHandler responseHandler = new 
BytesAccumulatingResponseHandler();
-      InputStream stream = httpClient.go(
-          new Request(HttpMethod.GET, segmentLoadingCapabilitiesURL)
-              .addHeader(HttpHeaders.Names.ACCEPT, MediaType.APPLICATION_JSON),
+      final URL url = getLoadCapabilitiesUrl();
+      final BytesAccumulatingResponseHandler responseHandler = new 
BytesAccumulatingResponseHandler();
+      final InputStream stream = httpClient.go(
+          new Request(HttpMethod.GET, url).addHeader(HttpHeaders.Names.ACCEPT, 
MediaType.APPLICATION_JSON),
           responseHandler,
           new Duration(DEFAULT_TIMEOUT)
       ).get();
 
-      if (HttpServletResponse.SC_NOT_FOUND == responseHandler.getStatus()) {
-        int batchSize = config.getBatchSize() == null ? 1 : 
config.getBatchSize();
-        SegmentLoadingCapabilities defaultCapabilities = new 
SegmentLoadingCapabilities(batchSize, batchSize);
-        log.warn(
-            "Historical capabilities endpoint not found at URL[%s]. Using 
default values[%s].",
-            segmentLoadingCapabilitiesURL,
-            defaultCapabilities
-        );
-        return defaultCapabilities;
-      } else if (HttpServletResponse.SC_OK != responseHandler.getStatus()) {
-        log.makeAlert("Received status[%s] when fetching loading capabilities 
from server[%s]", responseHandler.getStatus(), serverId);
-        throw new RE("Received status[%s] when fetching loading capabilities 
from server[%s]", responseHandler.getStatus(), serverId);
+      final int status = responseHandler.getStatus();
+      final SegmentLoadingCapabilities capabilities = 
interpretCapabilitiesResponse(status, stream, url);
+      if (!capabilitiesConfirmed) {
+        // Transient failure. Do not stall further processing due to a single 
unhealthy server:
+        // raise an alert and use default capabilities until the server 
recovers.
+        log.makeAlert(
+            "Received status[%s] when fetching loading capabilities from 
server[%s]. Using default values[%s].",
+            status,
+            serverId,
+            capabilities
+        ).emit();
       }
+      return capabilities;
+    }
+    catch (InterruptedException ie) {
+      Thread.currentThread().interrupt();
+      throw new RuntimeException(ie);
+    }
+    catch (Exception e) {
+      SegmentLoadingCapabilities defaultCapabilities = 
getDefaultLoadingCapabilities();
+      log.makeAlert(
+          e,
+          "Received error while fetching historical capabilities from 
Server[%s]. Using default values[%s].",
+          serverId,
+          defaultCapabilities
+      ).emit();
+      return defaultCapabilities;
+    }
+  }
+
+  /**
+   * Interprets a loadCapabilities response, setting {@link 
#capabilitiesConfirmed} and returning
+   * the capabilities to use. The value is confirmed on a real response (200) 
or a 404 (the endpoint
+   * is absent on this server, so retrying is pointless). A transient non-OK 
status yields default
+   * capabilities without confirming, so they are re-fetched on a later tick 
once the server recovers.
+   */
+  private SegmentLoadingCapabilities interpretCapabilitiesResponse(int status, 
InputStream stream, URL url)
+      throws IOException
+  {
+    if (HttpServletResponse.SC_NOT_FOUND == status) {
+      capabilitiesConfirmed = true;
+      SegmentLoadingCapabilities defaultCapabilities = 
getDefaultLoadingCapabilities();
+      log.warn(
+          "Historical capabilities endpoint not found at URL[%s]. Using 
default values[%s].",
+          url,
+          defaultCapabilities
+      );
+      return defaultCapabilities;
+    } else if (HttpServletResponse.SC_OK != status) {
+      return getDefaultLoadingCapabilities();
+    }
+
+    SegmentLoadingCapabilities capabilities = jsonMapper.readValue(stream, 
SegmentLoadingCapabilities.class);
+    capabilitiesConfirmed = true;
+    return capabilities;
+  }
+
+  /**
+   * Re-fetches loading capabilities if the peon is still pinned to default 
values from a
+   * transient failure during construction. Called on every segment management 
tick; a no-op
+   * once capabilities have been confirmed (the common case).
+   * <p>
+   * Unlike the construction path, this does not block the (single, shared) 
processing thread:
+   * the request is issued and its response handled in a callback, just like 
the segment change
+   * requests in {@link #doSegmentManagement()}. This keeps a single unhealthy 
server from
+   * stalling segment management for the rest of the cluster.
+   */
+  private void refetchCapabilitiesIfNeeded()
+  {
+    if (capabilitiesConfirmed || stopped || 
!refetchInProgress.compareAndSet(false, true)) {
+      return;
+    }
+
+    try {
+      final URL url = getLoadCapabilitiesUrl();
+      final BytesAccumulatingResponseHandler responseHandler = new 
BytesAccumulatingResponseHandler();
+      final ListenableFuture<InputStream> future = httpClient.go(
+          new Request(HttpMethod.GET, url).addHeader(HttpHeaders.Names.ACCEPT, 
MediaType.APPLICATION_JSON),
+          responseHandler,
+          new Duration(DEFAULT_TIMEOUT)
+      );
+
+      Futures.addCallback(
+          future,
+          new FutureCallback<>()
+          {
+            @Override
+            public void onSuccess(InputStream result)
+            {
+              try {
+                serverCapabilities = 
interpretCapabilitiesResponse(responseHandler.getStatus(), result, url);
+                if (capabilitiesConfirmed) {
+                  log.info("Refreshed loading capabilities[%s] for 
server[%s].", serverCapabilities, serverId);
+                }
+              }
+              catch (Throwable t) {
+                log.debug(t, "Could not parse refreshed loading capabilities 
from server[%s]. Will retry.", serverId);
+              }
+              finally {
+                refetchInProgress.set(false);
+              }
+            }
 
-      return jsonMapper.readValue(
-          stream,
-          SegmentLoadingCapabilities.class
+            @Override
+            public void onFailure(Throwable t)
+            {
+              log.debug(t, "Could not refresh loading capabilities from 
server[%s]. Will retry.", serverId);
+              refetchInProgress.set(false);
+            }
+          },
+          processingExecutor
       );
     }
     catch (Throwable th) {
-      throw new RE(th, "Received error while fetching historical capabilities 
from Server[%s].", serverId);
+      log.debug(th, "Error issuing capability refresh request to server[%s]. 
Will retry.", serverId);
+      refetchInProgress.set(false);
     }
   }
 
+  private SegmentLoadingCapabilities getDefaultLoadingCapabilities()
+  {
+    int batchSize = config.getBatchSize() == null ? 1 : config.getBatchSize();
+    return new SegmentLoadingCapabilities(batchSize, batchSize);
+  }
+
   private void doSegmentManagement()
   {
+    // Re-fetch loading capabilities if we are still pinned to defaults from a 
transient
+    // failure. This is async and a no-op once capabilities are confirmed, so 
it runs
+    // independently of the main loop below (which may bail out early if 
already in progress).
+    refetchCapabilitiesIfNeeded();
+
     if (stopped || !mainLoopInProgress.compareAndSet(false, true)) {
       log.trace("[%s]Ignoring tick. Either in-progress already or stopped.", 
serverId);
       return;
@@ -371,11 +510,12 @@ public class HttpLoadQueuePeon implements LoadQueuePeon
   @VisibleForTesting
   int calculateBatchSize(SegmentLoadingMode loadingMode)
   {
+    final SegmentLoadingCapabilities capabilities = serverCapabilities;
     int batchSize;
     if (SegmentLoadingMode.TURBO.equals(loadingMode)) {
-      batchSize = serverCapabilities.getNumTurboLoadingThreads();
+      batchSize = capabilities.getNumTurboLoadingThreads();
     } else {
-      batchSize = Configs.valueOrDefault(config.getBatchSize(), 
serverCapabilities.getNumLoadingThreads());
+      batchSize = Configs.valueOrDefault(config.getBatchSize(), 
capabilities.getNumLoadingThreads());
     }
 
     return Math.max(batchSize, 1);
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/DruidCoordinatorTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/DruidCoordinatorTest.java
index efa67668e8d..6e2c2a8f73f 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/DruidCoordinatorTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/DruidCoordinatorTest.java
@@ -19,9 +19,12 @@
 
 package org.apache.druid.server.coordinator;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2LongMap;
 import org.apache.druid.client.DataSourcesSnapshot;
@@ -33,16 +36,23 @@ import org.apache.druid.client.ServerInventoryView;
 import org.apache.druid.common.config.JacksonConfigManager;
 import org.apache.druid.discovery.DruidLeaderSelector;
 import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.common.concurrent.ScheduledExecutorFactory;
 import org.apache.druid.java.util.common.concurrent.ScheduledExecutors;
+import org.apache.druid.java.util.emitter.EmittingLogger;
 import org.apache.druid.java.util.emitter.core.Event;
+import org.apache.druid.java.util.emitter.service.AlertEvent;
 import org.apache.druid.java.util.emitter.service.ServiceEmitter;
 import org.apache.druid.java.util.emitter.service.ServiceMetricEvent;
+import org.apache.druid.java.util.http.client.HttpClient;
+import org.apache.druid.java.util.http.client.Request;
+import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
 import org.apache.druid.metadata.MetadataRuleManager;
 import org.apache.druid.metadata.MetadataRuleManagerConfig;
 import org.apache.druid.metadata.SegmentsMetadataManager;
 import org.apache.druid.metadata.segment.cache.NoopSegmentMetadataCache;
 import org.apache.druid.rpc.indexing.OverlordClient;
+import org.apache.druid.segment.TestHelper;
 import org.apache.druid.segment.metadata.CentralizedDatasourceSchemaConfig;
 import org.apache.druid.server.DruidNode;
 import org.apache.druid.server.compaction.CompactionSimulateResult;
@@ -53,6 +63,7 @@ import 
org.apache.druid.server.coordinator.config.CoordinatorKillConfigs;
 import org.apache.druid.server.coordinator.config.CoordinatorPeriodConfig;
 import org.apache.druid.server.coordinator.config.CoordinatorRunConfig;
 import org.apache.druid.server.coordinator.config.DruidCoordinatorConfig;
+import org.apache.druid.server.coordinator.config.HttpLoadQueuePeonConfig;
 import org.apache.druid.server.coordinator.config.MetadataCleanupConfig;
 import org.apache.druid.server.coordinator.duty.CompactSegments;
 import org.apache.druid.server.coordinator.duty.CoordinatorCustomDuty;
@@ -74,9 +85,15 @@ import org.apache.druid.server.coordinator.rules.Rule;
 import org.apache.druid.server.coordinator.stats.Stats;
 import org.apache.druid.server.http.BrokerDynamicConfigSyncer;
 import org.apache.druid.server.http.CoordinatorDynamicConfigSyncer;
+import org.apache.druid.server.http.SegmentLoadingCapabilities;
 import org.apache.druid.server.lookup.cache.LookupCoordinatorManager;
 import org.apache.druid.timeline.DataSegment;
 import org.easymock.EasyMock;
+import org.jboss.netty.buffer.ChannelBuffers;
+import org.jboss.netty.handler.codec.http.DefaultHttpResponse;
+import org.jboss.netty.handler.codec.http.HttpResponse;
+import org.jboss.netty.handler.codec.http.HttpResponseStatus;
+import org.jboss.netty.handler.codec.http.HttpVersion;
 import org.joda.time.Duration;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
@@ -85,11 +102,14 @@ import org.junit.jupiter.api.Timeout;
 import org.junit.jupiter.api.Timeout.ThreadMode;
 
 import javax.annotation.Nullable;
+import java.io.ByteArrayInputStream;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -908,6 +928,173 @@ public class DruidCoordinatorTest
     };
   }
 
+  /**
+   * End-to-end check via a real {@link DruidCoordinator} and {@link 
LoadQueueTaskMaster}:
+   * a single server returning HTTP 503 on {@code loadCapabilities} must not 
abort the
+   * {@code HistoricalManagementDuties} group. Asserts the group completes a 
run, no
+   * capabilities-related failure alert is emitted, and the healthy servers 
are still managed.
+   */
+  @Test
+  @Timeout(value = 60_000L, unit = TimeUnit.MILLISECONDS, threadMode = 
ThreadMode.SEPARATE_THREAD)
+  public void testUnhealthyHistoricalDoesNotAbortDutyGroup() throws Exception
+  {
+    EmittingLogger.registerEmitter(serviceEmitter);
+
+    EasyMock.expect(metadataRuleManager.getRulesSnapshot())
+            .andReturn(clusterDefaultRules(new 
ForeverLoadRule(ImmutableMap.of("tier1", 1), null)))
+            .anyTimes();
+    EasyMock.replay(metadataRuleManager);
+
+    final DruidDataSource dataSource = new DruidDataSource("ds", 
Collections.emptyMap());
+    dataSource.addSegment(new DataSegment("ds", 
Intervals.of("2010-01-01/P1D"), "v1", null, null, null, null, 0x9, 0));
+    setupSegmentsMetadataMock(dataSource);
+
+    // A cluster of healthy historicals plus a single unhealthy one that 503s 
on the
+    // loadCapabilities endpoint. The healthy servers must keep being managed.
+    final DruidServer healthy1 = new DruidServer("healthy1", "healthy1:8088", 
null, 100L, null, ServerType.HISTORICAL, "tier1", 0);
+    final DruidServer unhealthy = new DruidServer("unhealthy", 
"unhealthy:8088", null, 100L, null, ServerType.HISTORICAL, "tier1", 0);
+    final DruidServer healthy2 = new DruidServer("healthy2", "healthy2:8088", 
null, 100L, null, ServerType.HISTORICAL, "tier1", 0);
+    EasyMock.expect(serverInventoryView.getInventory())
+            .andReturn(ImmutableList.of(healthy1, unhealthy, healthy2))
+            .anyTimes();
+    
EasyMock.expect(serverInventoryView.isStarted()).andReturn(true).anyTimes();
+    EasyMock.replay(serverInventoryView);
+
+    // Build a coordinator wired with a REAL LoadQueueTaskMaster so that peon
+    // creation (and the failing capabilities fetch) actually happens. Only the
+    // unhealthy server 503s; the healthy ones answer normally.
+    final ScheduledExecutorService peonExec = 
Execs.scheduledSingleThreaded("Coordinator-peon-%s");
+    final ExecutorService callbackExec = 
Execs.singleThreaded("Coordinator-cb-%s");
+    final LoadQueueTaskMaster realTaskMaster = new LoadQueueTaskMaster(
+        TestHelper.makeJsonMapper(),
+        peonExec,
+        callbackExec,
+        new HttpLoadQueuePeonConfig(null, null, 10),
+        new SelectivelyFailingHttpClient("unhealthy"),
+        () -> CoordinatorDynamicConfig.builder().build()
+    );
+
+    final JacksonConfigManager configManager = 
EasyMock.createNiceMock(JacksonConfigManager.class);
+    
EasyMock.expect(configManager.watch(EasyMock.eq(CoordinatorDynamicConfig.CONFIG_KEY),
 EasyMock.anyObject(Class.class), EasyMock.anyObject()))
+            .andReturn(new 
AtomicReference<>(CoordinatorDynamicConfig.builder().build())).anyTimes();
+    
EasyMock.expect(configManager.watch(EasyMock.eq(DruidCompactionConfig.CONFIG_KEY),
 EasyMock.anyObject(Class.class), EasyMock.anyObject()))
+            .andReturn(new 
AtomicReference<>(DruidCompactionConfig.empty())).anyTimes();
+    EasyMock.replay(configManager);
+
+    final DruidCoordinator coordinatorWithRealTaskMaster = new 
DruidCoordinator(
+        druidCoordinatorConfig,
+        createMetadataManager(configManager),
+        serverInventoryView,
+        serviceEmitter,
+        scheduledExecutorFactory,
+        overlordClient,
+        realTaskMaster,
+        new SegmentLoadQueueManager(serverInventoryView, realTaskMaster),
+        new CoordinatorCustomDutyGroups(ImmutableSet.of()),
+        EasyMock.createNiceMock(LookupCoordinatorManager.class),
+        new TestDruidLeaderSelector(),
+        null,
+        CentralizedDatasourceSchemaConfig.create(),
+        new CompactionStatusTracker(),
+        EasyMock.niceMock(CoordinatorDynamicConfigSyncer.class),
+        EasyMock.niceMock(BrokerDynamicConfigSyncer.class),
+        new CloneStatusManager()
+    );
+
+    try {
+      coordinatorWithRealTaskMaster.start();
+
+      // The HistoricalManagementDuties group must actually complete a run 
despite the
+      // sick historical. GROUP_RUN_TIME is emitted only at the end of a full 
group run,
+      // so this latch (2 runs) only trips if the group is not being aborted.
+      Assertions.assertTrue(
+          serviceEmitter.coordinatorRunLatch.await(30, 
java.util.concurrent.TimeUnit.SECONDS),
+          "HistoricalManagementDuties group did not complete a run; one 
unhealthy historical aborted it"
+      );
+
+      // And the coordinator must not have hit the catch-all that logs
+      // "Caught exception, ignoring so that schedule keeps going." for a 
failure in the
+      // load-queue/peon-preparation path.
+      Assertions.assertNull(
+          serviceEmitter.dutyGroupFailureAlert.get(),
+          "Coordinator aborted the duty group with an exception for a single 
unhealthy historical"
+      );
+
+      Assertions.assertTrue(coordinatorWithRealTaskMaster.isLeader());
+
+      // The healthy servers must still be managed (they have peons); only the
+      // unhealthy one may be missing.
+      Assertions.assertNotNull(
+          realTaskMaster.getPeonForServer(healthy1.toImmutableDruidServer()),
+          "Healthy server healthy1 was never managed because an unhealthy 
server aborted the reconcile"
+      );
+      Assertions.assertNotNull(
+          realTaskMaster.getPeonForServer(healthy2.toImmutableDruidServer()),
+          "Healthy server healthy2 was never managed because an unhealthy 
server aborted the reconcile"
+      );
+    }
+    finally {
+      coordinatorWithRealTaskMaster.stop();
+      peonExec.shutdownNow();
+      callbackExec.shutdownNow();
+    }
+  }
+
+  /**
+   * HttpClient that answers the {@code loadCapabilities} call, returning HTTP 
503 for a
+   * single named server and a valid capabilities payload for everyone else.
+   */
+  private static class SelectivelyFailingHttpClient implements HttpClient
+  {
+    private static final ObjectMapper MAPPER = TestHelper.makeJsonMapper();
+
+    private final String failingServer;
+
+    SelectivelyFailingHttpClient(String failingServer)
+    {
+      this.failingServer = failingServer;
+    }
+
+    @Override
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler
+    )
+    {
+      return go(request, handler, null);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler,
+        Duration duration
+    )
+    {
+      final String url = request.getUrl().toString();
+      final boolean isFailing = failingServer != null && 
url.contains(failingServer);
+
+      final HttpResponseStatus status = isFailing
+                                        ? 
HttpResponseStatus.SERVICE_UNAVAILABLE
+                                        : HttpResponseStatus.OK;
+      final HttpResponse httpResponse = new 
DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
+      httpResponse.setContent(ChannelBuffers.buffer(0));
+      handler.handleResponse(httpResponse, null);
+
+      try {
+        final byte[] body = isFailing
+                            ? new byte[0]
+                            : 
MAPPER.writerFor(SegmentLoadingCapabilities.class)
+                                    .writeValueAsBytes(new 
SegmentLoadingCapabilities(1, 3));
+        return (ListenableFuture<Final>) Futures.immediateFuture(new 
ByteArrayInputStream(body));
+      }
+      catch (Exception e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
   private static class TestDruidLeaderSelector implements DruidLeaderSelector
   {
     private volatile Listener listener;
@@ -951,6 +1138,13 @@ public class DruidCoordinatorTest
   {
     private final CountDownLatch coordinatorRunLatch = new CountDownLatch(2);
 
+    /**
+     * Set when {@code DruidCoordinator.DutiesRunnable.run()} catches an 
exception thrown
+     * while preparing the balancer / load queues (peon creation + 
capabilities fetch) and
+     * emits its "Caught exception, ignoring so that schedule keeps going." 
alert.
+     */
+    private final AtomicReference<String> dutyGroupFailureAlert = new 
AtomicReference<>();
+
     private LatchableServiceEmitter()
     {
       super("", "", null);
@@ -968,6 +1162,22 @@ public class DruidCoordinatorTest
             && "HistoricalManagementDuties".equals(dutyGroupName)) {
           coordinatorRunLatch.countDown();
         }
+      } else if (event instanceof AlertEvent) {
+        final AlertEvent alert = (AlertEvent) event;
+        // The coordinator's top-level catch logs this exact description for a 
failure in ANY duty
+        // group. This test's harness intentionally leaves several 
collaborators (audit manager,
+        // supervisor manager, etc.) null, so unrelated duty groups such as 
IndexingServiceDuties emit
+        // this alert too. Scope detection to the bug under test: an exception 
thrown while preparing
+        // the balancer / load queues (peon creation + capabilities fetch) for 
HistoricalManagementDuties.
+        if ("Caught exception, ignoring so that schedule keeps 
going.".equals(alert.getDescription())) {
+          final Object stackTrace = 
alert.getDataMap().get("exceptionStackTrace");
+          if (stackTrace != null
+              && (stackTrace.toString().contains("HttpLoadQueuePeon")
+                  || 
stackTrace.toString().contains("PrepareBalancerAndLoadQueues")
+                  || 
stackTrace.toString().contains("resetPeonsForNewServers"))) {
+            dutyGroupFailureAlert.set(alert.getDescription());
+          }
+        }
       }
     }
   }
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeonTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeonTest.java
index 1520e9f5b35..7ad3049cbd2 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeonTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/HttpLoadQueuePeonTest.java
@@ -24,6 +24,7 @@ import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import org.apache.druid.java.util.common.RE;
 import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.emitter.EmittingLogger;
 import org.apache.druid.java.util.http.client.HttpClient;
 import org.apache.druid.java.util.http.client.Request;
 import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
@@ -39,6 +40,7 @@ import 
org.apache.druid.server.coordinator.simulate.BlockingExecutorService;
 import 
org.apache.druid.server.coordinator.simulate.WrappingScheduledExecutorService;
 import org.apache.druid.server.http.SegmentLoadingCapabilities;
 import org.apache.druid.server.http.SegmentLoadingMode;
+import org.apache.druid.server.metrics.NoopServiceEmitter;
 import org.apache.druid.timeline.DataSegment;
 import org.jboss.netty.buffer.ChannelBuffers;
 import org.jboss.netty.handler.codec.http.DefaultHttpResponse;
@@ -58,6 +60,7 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
@@ -78,6 +81,7 @@ public class HttpLoadQueuePeonTest
   @BeforeEach
   public void setUp()
   {
+    EmittingLogger.registerEmitter(new NoopServiceEmitter());
     segmentLoadingCapabilities = new SegmentLoadingCapabilities(1, 3);
     httpClient = new TestHttpClient();
     httpLoadQueuePeon = new HttpLoadQueuePeon(
@@ -321,6 +325,123 @@ public class HttpLoadQueuePeonTest
     );
   }
 
+  @Test
+  public void testPeonIsCreatedWhenServerErrorsOnLoadCapabilities()
+  {
+    // A server returning a non-OK, non-404 status (e.g. 503) on 
loadCapabilities must not
+    // fail peon construction. The peon falls back to default capabilities so 
the server is
+    // still managed, rather than throwing and aborting segment management for 
the cluster.
+    final HttpClient failingClient = new HttpClient()
+    {
+      @Override
+      public <Intermediate, Final> ListenableFuture<Final> go(
+          Request request,
+          HttpResponseHandler<Intermediate, Final> handler
+      )
+      {
+        return go(request, handler, null);
+      }
+
+      @Override
+      @SuppressWarnings("unchecked")
+      public <Intermediate, Final> ListenableFuture<Final> go(
+          Request request,
+          HttpResponseHandler<Intermediate, Final> handler,
+          Duration duration
+      )
+      {
+        respond(handler, HttpResponseStatus.SERVICE_UNAVAILABLE);
+        return (ListenableFuture<Final>) Futures.immediateFuture(new 
ByteArrayInputStream(new byte[0]));
+      }
+    };
+
+    final HttpLoadQueuePeon peon = new HttpLoadQueuePeon(
+        "http://dummy:4000";,
+        MAPPER,
+        failingClient,
+        new HttpLoadQueuePeonConfig(null, null, 10),
+        () -> SegmentLoadingMode.NORMAL,
+        new WrappingScheduledExecutorService("HttpLoadQueuePeonTest-%s", 
httpClient.processingExecutor, true),
+        httpClient.callbackExecutor
+    );
+
+    // Construction succeeded and the peon fell back to default capabilities 
derived from the batch
+    // size: turbo loading threads default to the batch size (10) rather than 
a fetched value.
+    Assertions.assertEquals(10, 
peon.calculateBatchSize(SegmentLoadingMode.TURBO));
+  }
+
+  @Test
+  public void testCapabilitiesAreReFetchedAfterServerRecovers()
+  {
+    // Regression test for the review finding on #19950. If a server is 
unhealthy when the peon is
+    // constructed, the peon falls back to default capabilities. Since 
LoadQueueTaskMaster reuses the
+    // same peon while the server stays in inventory, the peon must re-fetch 
capabilities on a later
+    // tick once the server recovers, rather than staying pinned to the 
defaults forever.
+    final AtomicInteger loadCapabilitiesCalls = new AtomicInteger(0);
+    final RecoveringHttpClient recoveringClient = new 
RecoveringHttpClient(loadCapabilitiesCalls);
+
+    final HttpLoadQueuePeon peon = new HttpLoadQueuePeon(
+        "http://dummy:4000";,
+        MAPPER,
+        recoveringClient,
+        new HttpLoadQueuePeonConfig(null, null, 10),
+        () -> SegmentLoadingMode.NORMAL,
+        new WrappingScheduledExecutorService("HttpLoadQueuePeonTest-%s", 
recoveringClient.processingExecutor, true),
+        recoveringClient.callbackExecutor
+    );
+
+    // The probe issued during construction failed, so the peon falls back to 
default capabilities and
+    // reports the default batch size (10) rather than the real turbo 
capability (3).
+    Assertions.assertEquals(1, loadCapabilitiesCalls.get());
+    Assertions.assertEquals(10, 
peon.calculateBatchSize(SegmentLoadingMode.TURBO));
+
+    // The server has since recovered. Trigger a segment management tick: the 
peon issues an async
+    // re-fetch, whose callback runs on the processing executor and updates 
the cached capabilities.
+    peon.loadSegment(segments.get(0), SegmentAction.LOAD, null);
+    recoveringClient.processingExecutor.finishAllPendingTasks();
+
+    // The peon picked up the real turbo capability (3) instead of remaining 
pinned to the default (10).
+    Assertions.assertTrue(loadCapabilitiesCalls.get() > 1);
+    Assertions.assertEquals(3, 
peon.calculateBatchSize(SegmentLoadingMode.TURBO));
+  }
+
+  @Test
+  public void testOnlyOneCapabilitiesProbeIsOutstandingAtATime()
+  {
+    // A still-unhealthy server should have at most one outstanding capability 
probe at a time: while
+    // one is in flight, other doSegmentManagement() ticks must not issue 
their own duplicate probes.
+    final AtomicInteger loadCapabilitiesCalls = new AtomicInteger(0);
+    final AlwaysUnhealthyHttpClient unhealthyClient = new 
AlwaysUnhealthyHttpClient(loadCapabilitiesCalls);
+
+    final HttpLoadQueuePeon peon = new HttpLoadQueuePeon(
+        "http://dummy:4000";,
+        MAPPER,
+        unhealthyClient,
+        new HttpLoadQueuePeonConfig(null, null, 10),
+        () -> SegmentLoadingMode.NORMAL,
+        new WrappingScheduledExecutorService("HttpLoadQueuePeonTest-%s", 
unhealthyClient.processingExecutor, true),
+        unhealthyClient.callbackExecutor
+    );
+
+    // The probe issued during construction failed, so capabilities remain 
unconfirmed.
+    Assertions.assertEquals(1, loadCapabilitiesCalls.get());
+
+    // Queue every available segment onto the still-unhealthy peon before 
draining anything, i.e.
+    // before any prior probe has had a chance to resolve. Each loadSegment() 
call queues exactly one
+    // doSegmentManagement() tick.
+    for (DataSegment segment : segments) {
+      peon.loadSegment(segment, SegmentAction.LOAD, null);
+    }
+
+    // Run exactly those queued ticks, without letting any capability-probe 
callback resolve yet (the
+    // callback for the first tick's probe is enqueued behind them, so it 
isn't among these).
+    unhealthyClient.processingExecutor.finishNextPendingTasks(segments.size());
+
+    // Only the first of those ticks should have issued a probe; the rest must 
see one already
+    // outstanding and skip theirs, instead of each firing its own duplicate 
at the struggling server.
+    Assertions.assertEquals(2, loadCapabilitiesCalls.get());
+  }
+
   @Test
   public void testBatchSize()
   {
@@ -350,6 +471,13 @@ public class HttpLoadQueuePeonTest
     return success -> httpClient.processedSegments.add(segment);
   }
 
+  private static void respond(HttpResponseHandler<?, ?> handler, 
HttpResponseStatus status)
+  {
+    final HttpResponse response = new 
DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
+    response.setContent(ChannelBuffers.buffer(0));
+    handler.handleResponse(response, null);
+  }
+
   private class TestHttpClient implements HttpClient, DataSegmentChangeHandler
   {
     final BlockingExecutorService processingExecutor = new 
BlockingExecutorService("HttpLoadQueuePeonTest-%s");
@@ -447,6 +575,139 @@ public class HttpLoadQueuePeonTest
     }
   }
 
+  /**
+   * An {@link HttpClient} for a server whose loadCapabilities endpoint 
returns 503 on the first call
+   * (simulating an unhealthy server at peon construction) and real 
capabilities (1, 3) thereafter
+   * (simulating recovery). Segment change requests always succeed.
+   */
+  private static class RecoveringHttpClient implements HttpClient
+  {
+    final BlockingExecutorService processingExecutor = new 
BlockingExecutorService("HttpLoadQueuePeonTest-%s");
+    final BlockingExecutorService callbackExecutor = new 
BlockingExecutorService("HttpLoadQueuePeonTest-cb");
+    private final AtomicInteger loadCapabilitiesCalls;
+
+    RecoveringHttpClient(AtomicInteger loadCapabilitiesCalls)
+    {
+      this.loadCapabilitiesCalls = loadCapabilitiesCalls;
+    }
+
+    @Override
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler
+    )
+    {
+      return go(request, handler, null);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler,
+        Duration duration
+    )
+    {
+      try {
+        if (request.getUrl().toString().contains("/loadCapabilities")) {
+          // First probe (during construction) fails; the server is healthy on 
every probe after that.
+          final boolean serverIsHealthy = 
loadCapabilitiesCalls.getAndIncrement() > 0;
+          if (serverIsHealthy) {
+            respond(handler, HttpResponseStatus.OK);
+            return (ListenableFuture<Final>) Futures.immediateFuture(
+                new ByteArrayInputStream(
+                    MAPPER.writerFor(SegmentLoadingCapabilities.class)
+                          .writeValueAsBytes(new SegmentLoadingCapabilities(1, 
3))
+                )
+            );
+          }
+          respond(handler, HttpResponseStatus.SERVICE_UNAVAILABLE);
+          return (ListenableFuture<Final>) Futures.immediateFuture(new 
ByteArrayInputStream(new byte[0]));
+        }
+
+        // Segment change request: acknowledge every queued segment as 
successfully processed.
+        respond(handler, HttpResponseStatus.OK);
+        final List<DataSegmentChangeRequest> changeRequests = MAPPER.readValue(
+            request.getContent().array(),
+            HttpLoadQueuePeon.REQUEST_ENTITY_TYPE_REF
+        );
+        final List<DataSegmentChangeResponse> statuses = new 
ArrayList<>(changeRequests.size());
+        for (DataSegmentChangeRequest cr : changeRequests) {
+          statuses.add(new DataSegmentChangeResponse(cr, 
SegmentChangeStatus.success()));
+        }
+        return (ListenableFuture<Final>) Futures.immediateFuture(
+            new ByteArrayInputStream(
+                
MAPPER.writerFor(HttpLoadQueuePeon.RESPONSE_ENTITY_TYPE_REF).writeValueAsBytes(statuses)
+            )
+        );
+      }
+      catch (Exception ex) {
+        throw new RE(ex, "Unexpected exception.");
+      }
+    }
+  }
+
+  /**
+   * An {@link HttpClient} whose loadCapabilities endpoint always returns 503, 
simulating a server
+   * that never recovers. Segment change requests always succeed.
+   */
+  private static class AlwaysUnhealthyHttpClient implements HttpClient
+  {
+    final BlockingExecutorService processingExecutor = new 
BlockingExecutorService("HttpLoadQueuePeonTest-%s");
+    final BlockingExecutorService callbackExecutor = new 
BlockingExecutorService("HttpLoadQueuePeonTest-cb");
+    private final AtomicInteger loadCapabilitiesCalls;
+
+    AlwaysUnhealthyHttpClient(AtomicInteger loadCapabilitiesCalls)
+    {
+      this.loadCapabilitiesCalls = loadCapabilitiesCalls;
+    }
+
+    @Override
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler
+    )
+    {
+      return go(request, handler, null);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <Intermediate, Final> ListenableFuture<Final> go(
+        Request request,
+        HttpResponseHandler<Intermediate, Final> handler,
+        Duration duration
+    )
+    {
+      try {
+        if (request.getUrl().toString().contains("/loadCapabilities")) {
+          loadCapabilitiesCalls.incrementAndGet();
+          respond(handler, HttpResponseStatus.SERVICE_UNAVAILABLE);
+          return (ListenableFuture<Final>) Futures.immediateFuture(new 
ByteArrayInputStream(new byte[0]));
+        }
+
+        // Segment change request: acknowledge every queued segment as 
successfully processed.
+        respond(handler, HttpResponseStatus.OK);
+        final List<DataSegmentChangeRequest> changeRequests = MAPPER.readValue(
+            request.getContent().array(),
+            HttpLoadQueuePeon.REQUEST_ENTITY_TYPE_REF
+        );
+        final List<DataSegmentChangeResponse> statuses = new 
ArrayList<>(changeRequests.size());
+        for (DataSegmentChangeRequest cr : changeRequests) {
+          statuses.add(new DataSegmentChangeResponse(cr, 
SegmentChangeStatus.success()));
+        }
+        return (ListenableFuture<Final>) Futures.immediateFuture(
+            new ByteArrayInputStream(
+                
MAPPER.writerFor(HttpLoadQueuePeon.RESPONSE_ENTITY_TYPE_REF).writeValueAsBytes(statuses)
+            )
+        );
+      }
+      catch (Exception ex) {
+        throw new RE(ex, "Unexpected exception.");
+      }
+    }
+  }
+
   /**
    * Represents an action that can be performed on a segment by calling {@link 
#invoke()}.
    */


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

Reply via email to