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

capistrant 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 640609453e5 feat: For partial loads, hook into existing strategic 
assigner code to better distribute partial load requests (#20155)
640609453e5 is described below

commit 640609453e58ee64ae2af133584d14e8374c43f5
Author: Lucas Capistrant <[email protected]>
AuthorDate: Wed Aug 26 16:51:04 2026 -0500

    feat: For partial loads, hook into existing strategic assigner code to 
better distribute partial load requests (#20155)
    
    * Use existing strategic assignment code for partial loads to improve 
segment distribution
    
    * clarify doc on why using the supplied iterator works for fresh loads
---
 .../loading/StrategicSegmentAssigner.java          |  71 +++++--
 .../duty/RunRulesPartialLoadPlacementTest.java     | 207 ++++++++++++++++++++-
 .../StrategicSegmentAssignerPartialTest.java       |  45 +++++
 3 files changed, 296 insertions(+), 27 deletions(-)

diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
index 8982dbb400d..ab95ed09cae 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
@@ -19,6 +19,7 @@
 
 package org.apache.druid.server.coordinator.loading;
 
+import com.google.common.collect.Iterators;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import org.apache.druid.client.DruidServer;
 import org.apache.druid.server.coordinator.DruidCluster;
@@ -488,10 +489,16 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
   }
 
   /**
-   * Queues fresh partial-load requests on up to {@code numToLoad} eligible 
servers. Preference order: empty
-   * (fresh-load) servers first; then servers whose stale-fingerprint 
in-flight loads were just canceled (their slot
-   * is now free); then stale-loaded servers (additive reload; the historical 
fills missing parts in place). The last
-   * fallback is what mitigates the "tier saturated with stale" stuck state.
+   * Queues fresh partial-load requests on up to {@code numToLoad} eligible 
servers. Preference order: servers that can
+   * take a fresh load, then stale-loaded servers (additive reload; the 
historical fills missing parts in place).
+   * <p>
+   * The fresh-load candidates are the classifier's empty servers together 
with {@code canceledStaleServers}.
+   * {@link ServerHolder#cancelOperation} clears the queued action and 
restores the projected size, so a server whose
+   * stale in-flight load {@link #cancelLoadsOnServers} canceled can take a 
fresh load. {@link #serversToLoadSegment}
+   * returns an iterator over fresh load candidates.
+   * <p>
+   * An iterator over candidates that can additive reload the segment is there 
for backup in case we can't fully
+   * replicate on our priority one fresh load path.
    */
   private int loadPartialReplicas(
       int numToLoad,
@@ -509,25 +516,28 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
       return 0;
     }
 
-    final List<ServerHolder> destinations = new ArrayList<>(
-        status.getEligibleForFreshLoad().size()
-        + canceledStaleServers.size()
-        + status.getEligibleForAdditiveReload().size()
+    // The classifier's list is already the complete candidate set when 
nothing was canceled.
+    final List<ServerHolder> freshCandidates;
+    if (canceledStaleServers.isEmpty()) {
+      freshCandidates = status.getEligibleForFreshLoad();
+    } else {
+      freshCandidates = new ArrayList<>(status.getEligibleForFreshLoad());
+      freshCandidates.addAll(canceledStaleServers);
+    }
+
+    final Iterator<ServerHolder> destinations = Iterators.concat(
+        serversToLoadSegment(segment, tier, freshCandidates),
+        status.getEligibleForAdditiveReload().iterator()
     );
-    destinations.addAll(status.getEligibleForFreshLoad());
-    destinations.addAll(canceledStaleServers);
-    destinations.addAll(status.getEligibleForAdditiveReload());
 
-    if (destinations.isEmpty()) {
+    if (!destinations.hasNext()) {
       incrementSkipStat(Stats.Segments.ASSIGN_SKIPPED, "No eligible server", 
segment, tier);
       return 0;
     }
 
     int numLoadsQueued = 0;
-    for (ServerHolder server : destinations) {
-      if (numLoadsQueued >= numToLoad) {
-        break;
-      }
+    while (numLoadsQueued < numToLoad && destinations.hasNext()) {
+      final ServerHolder server = destinations.next();
       final boolean queuedSuccessfully = isAlreadyLoadedOnTier
                                          ? replicateSegment(segment, server, 
profile)
                                          : loadSegment(segment, server, 
profile);
@@ -538,6 +548,30 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
     return numLoadsQueued;
   }
 
+  /**
+   * Orders {@code eligibleServers} into the sequence a load should try them 
in: round robin across the tier when
+   * round-robin assignment is enabled, else by the balancer strategy.
+   * <p>
+   * The round-robin branch ignores {@code eligibleServers} and derives its 
candidates from the tier, keeping those
+   * that pass {@link ServerHolder#canLoadSegment} at the moment each one is 
taken. Callers pass their complete
+   * eligible set: the two branches otherwise disagree on which servers a load 
may target, and an empty
+   * {@code eligibleServers} does not imply an empty iterator.
+   * <p>
+   * Consume the result lazily. {@link RoundRobinServerSelector} advances a 
per-tier cursor on every element taken, so
+   * draining the iterator for a segment that needs one replica advances the 
cursor a full lap and hands the next
+   * segment the same starting server.
+   */
+  private Iterator<ServerHolder> serversToLoadSegment(
+      DataSegment segment,
+      String tier,
+      List<ServerHolder> eligibleServers
+  )
+  {
+    return useRoundRobinAssignment
+           ? serverSelector.getServersInTierToLoadSegment(tier, segment)
+           : strategy.findServersToLoadSegment(segment, eligibleServers);
+  }
+
   /**
    * Cancels up to {@code numToCancel} in-flight load operations across the 
given list of servers. Successfully
    * canceled servers are appended to {@code canceledOut} so the caller can 
re-target them as fresh-load
@@ -951,10 +985,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
       return 0;
     }
 
-    final Iterator<ServerHolder> serverIterator =
-        useRoundRobinAssignment
-        ? serverSelector.getServersInTierToLoadSegment(tier, segment)
-        : strategy.findServersToLoadSegment(segment, eligibleServers);
+    final Iterator<ServerHolder> serverIterator = 
serversToLoadSegment(segment, tier, eligibleServers);
     if (!serverIterator.hasNext()) {
       incrementSkipStat(Stats.Segments.ASSIGN_SKIPPED, "No strategic server", 
segment, tier);
       return 0;
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/duty/RunRulesPartialLoadPlacementTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/duty/RunRulesPartialLoadPlacementTest.java
index 46e2284503e..bc2de75a1a6 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/duty/RunRulesPartialLoadPlacementTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/duty/RunRulesPartialLoadPlacementTest.java
@@ -30,12 +30,14 @@ import org.apache.druid.metadata.MetadataRuleManagerConfig;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.server.coordination.ServerType;
-import org.apache.druid.server.coordinator.CoordinatorDynamicConfig;
 import org.apache.druid.server.coordinator.DruidCluster;
 import org.apache.druid.server.coordinator.DruidCoordinatorRuntimeParams;
 import org.apache.druid.server.coordinator.ServerHolder;
 import org.apache.druid.server.coordinator.balancer.BalancerStrategy;
 import org.apache.druid.server.coordinator.balancer.CostBalancerStrategy;
+import org.apache.druid.server.coordinator.loading.PartialLoadProfile;
+import org.apache.druid.server.coordinator.loading.SegmentAction;
+import org.apache.druid.server.coordinator.loading.SegmentHolder;
 import org.apache.druid.server.coordinator.loading.SegmentLoadQueueManager;
 import org.apache.druid.server.coordinator.loading.TestLoadQueuePeon;
 import org.apache.druid.server.coordinator.rules.CannotMatchBehavior;
@@ -51,6 +53,7 @@ import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
 import org.apache.druid.timeline.partition.NumberedShardSpec;
 import org.joda.time.DateTime;
+import org.joda.time.Duration;
 import org.joda.time.Interval;
 import org.joda.time.Period;
 import org.junit.jupiter.api.AfterEach;
@@ -58,7 +61,9 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -73,6 +78,13 @@ public class RunRulesPartialLoadPlacementTest
   private static final String TIER = "tier1";
   private static final Interval CHUNK = Intervals.of("2026-01-01/2026-01-02");
 
+  /**
+   * Announced profile for a replica loaded under some earlier rule. Its 
fingerprint matches no rule used here, so
+   * the reconciler classifies any replica carrying it as stale.
+   */
+  private static final PartialLoadProfile STALE_PROFILE =
+      PartialLoadProfile.forLoaded(Map.of("type", "partialClusterGroup"), 
"v1:previous-rule", 1L);
+
   private ListeningExecutorService exec;
   private BalancerStrategy balancerStrategy;
   private SegmentLoadQueueManager loadQueueManager;
@@ -188,6 +200,121 @@ public class RunRulesPartialLoadPlacementTest
     Assertions.assertFalse(stats.hasStat(Stats.Segments.ASSIGNED), "no 
partition is fully downloaded");
   }
 
+  /**
+   * Fresh partial loads must be distributed across the historicals of a tier. 
With three empty historicals and one
+   * required replica, each historical should end up holding roughly a third 
of the assignments.
+   */
+  @Test
+  public void test_freshPartialLoads_areSpreadAcrossHistoricals()
+  {
+    final List<DataSegment> segments = dailySegments(12, "acme");
+
+    final Map<String, TestLoadQueuePeon> peons = new LinkedHashMap<>();
+    final DruidCluster cluster = tierOf(peons, "hist1", "hist2", "hist3");
+
+    runRules(cluster, matchTenantForeverRule("acme"), segments.toArray(new 
DataSegment[0]));
+
+    assertAssignmentsSpreadEvenly(peons, segments.size());
+  }
+
+  /**
+   * A rule change that invalidates every loaded replica must spread the 
replacement loads across the tier. Each
+   * historical starts out holding a third of the replicas under a fingerprint 
the new rule does not match; the
+   * reloads that reconcile them should again land roughly a third on each 
historical.
+   */
+  @Test
+  public void 
test_ruleChangeInvalidatingEveryReplica_spreadsReloadsAcrossHistoricals()
+  {
+    final List<DataSegment> segments = dailySegments(12, "acme");
+
+    // Deal the segments evenly across the three historicals under a 
fingerprint the new rule will not match.
+    final Map<String, List<DataSegment>> preloaded = new LinkedHashMap<>();
+    final List<String> names = List.of("hist1", "hist2", "hist3");
+    names.forEach(name -> preloaded.put(name, new ArrayList<>()));
+    for (int i = 0; i < segments.size(); i++) {
+      preloaded.get(names.get(i % names.size())).add(segments.get(i));
+    }
+
+    final Map<String, TestLoadQueuePeon> peons = new LinkedHashMap<>();
+    final DruidCluster cluster = tierOfPreloadedHistoricals(peons, preloaded);
+
+    runRules(cluster, matchTenantForeverRule("acme"), segments.toArray(new 
DataSegment[0]));
+
+    assertAssignmentsSpreadEvenly(peons, segments.size());
+  }
+
+  /**
+   * A deficit larger than the number of historicals that can take a fresh 
load must still queue each historical at
+   * most once. The historical whose stale in-flight load is cancelled becomes 
a fresh-load candidate, and the
+   * stale-loaded historical takes an additive reload; a skip stat here means 
one of them was offered twice.
+   */
+  @Test
+  public void 
test_ruleChangeWithStaleInFlightAndStaleLoaded_queuesEachHistoricalOnce()
+  {
+    final DataSegment segment = dailySegments(1, "acme").getFirst();
+
+    // hist1 is mid-load under a fingerprint the new rule does not match, so 
cancelling frees it for a fresh load.
+    final TestLoadQueuePeon inFlightPeon = new TestLoadQueuePeon();
+    inFlightPeon.addInFlightHolder(
+        new SegmentHolder(segment, SegmentAction.LOAD, STALE_PROFILE, 
Duration.standardSeconds(10), null)
+    );
+    final ServerHolder hist1 = new 
ServerHolder(server("hist1").toImmutableDruidServer(), inFlightPeon);
+
+    // hist2 already serves the segment under that same stale fingerprint, so 
it can only reload additively.
+    final DruidServer staleServer = server("hist2");
+    staleServer.addDataSegment(segment, STALE_PROFILE);
+    final TestLoadQueuePeon stalePeon = new TestLoadQueuePeon();
+    final ServerHolder hist2 = new 
ServerHolder(staleServer.toImmutableDruidServer(), stalePeon);
+
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER, hist1, 
hist2).build();
+
+    final CoordinatorRunStats stats = runRules(cluster, 
matchTenantForeverRule("acme", 2), segment);
+
+    Assertions.assertEquals(
+        1L,
+        stats.getSegmentStat(Stats.Segments.PARTIAL_STALE_CANCELLED, TIER, 
DATASOURCE),
+        "the stale in-flight load is cancelled"
+    );
+    Assertions.assertEquals(
+        2L,
+        stats.getSegmentStat(Stats.Segments.PARTIAL_ASSIGNED, TIER, 
DATASOURCE),
+        "both required replicas are queued"
+    );
+    Assertions.assertFalse(
+        stats.hasStat(Stats.Segments.ASSIGN_SKIPPED),
+        "each historical is offered once, so no assignment is skipped"
+    );
+    Assertions.assertNotEquals(
+        STALE_PROFILE,
+        inFlightPeon.getProfileFor(segment),
+        "the cancelled historical is reloaded under the rule's fingerprint"
+    );
+    Assertions.assertNotNull(
+        stalePeon.getProfileFor(segment),
+        "the stale-loaded historical is queued an additive reload"
+    );
+  }
+
+  /**
+   * Asserts that every segment was assigned somewhere and that no historical 
carries more than one assignment more
+   * than any other. The failure message reports the per-historical counts so 
an uneven split is readable directly.
+   */
+  private static void assertAssignmentsSpreadEvenly(Map<String, 
TestLoadQueuePeon> peons, int expectedTotal)
+  {
+    final Map<String, Integer> counts = new LinkedHashMap<>();
+    peons.forEach((name, peon) -> counts.put(name, 
peon.getSegmentsToLoad().size()));
+
+    final int total = 
counts.values().stream().mapToInt(Integer::intValue).sum();
+    Assertions.assertEquals(expectedTotal, total, "every segment is assigned 
somewhere, but counts were " + counts);
+
+    final int max = 
counts.values().stream().mapToInt(Integer::intValue).max().orElse(0);
+    final int min = 
counts.values().stream().mapToInt(Integer::intValue).min().orElse(0);
+    Assertions.assertTrue(
+        max - min <= 1,
+        "assignments are spread across the tier's historicals, but counts were 
" + counts
+    );
+  }
+
   private ForeverPartialLoadRule matchNobodyForeverRule()
   {
     // Include pattern resolves against the "tenant" clustering column 
(compatible) but matches none of the segments'
@@ -221,12 +348,6 @@ public class RunRulesPartialLoadPlacementTest
         )
         .withUsedSegments(segments)
         .withBalancerStrategy(balancerStrategy)
-        .withDynamicConfigs(
-            CoordinatorDynamicConfig.builder()
-                                    .withSmartSegmentLoading(false)
-                                    .withUseRoundRobinSegmentAssignment(false)
-                                    .build()
-        )
         .withSegmentAssignerUsing(loadQueueManager)
         .build();
 
@@ -234,11 +355,62 @@ public class RunRulesPartialLoadPlacementTest
     return params.getCoordinatorStats();
   }
 
+  private static ForeverPartialLoadRule matchTenantForeverRule(String tenant)
+  {
+    return matchTenantForeverRule(tenant, 1);
+  }
+
+  private static ForeverPartialLoadRule matchTenantForeverRule(String tenant, 
int replicas)
+  {
+    return new ForeverPartialLoadRule(
+        ImmutableMap.of(TIER, replicas),
+        null,
+        new WildcardClusterGroupPartialLoadMatcher(List.of(Map.of("tenant", 
tenant)), null),
+        CannotMatchBehavior.FULL_LOAD
+    );
+  }
+
   private static DruidCluster singleTierCluster()
   {
     return DruidCluster.builder().addTier(TIER, historical("hist1", 
TIER)).build();
   }
 
+  /**
+   * Builds a tier of empty historicals, exposing each one's peon by name so a 
test can read back what was assigned
+   * to it.
+   */
+  private static DruidCluster tierOf(Map<String, TestLoadQueuePeon> peonsOut, 
String... names)
+  {
+    final DruidCluster.Builder cluster = DruidCluster.builder();
+    for (String name : names) {
+      final TestLoadQueuePeon peon = new TestLoadQueuePeon();
+      peonsOut.put(name, peon);
+      cluster.add(new ServerHolder(server(name).toImmutableDruidServer(), 
peon));
+    }
+    return cluster.build();
+  }
+
+  /**
+   * Builds a tier whose historicals already serve the given segments under 
{@link #STALE_PROFILE}, so a rule
+   * resolving to any other fingerprint sees every replica as stale.
+   */
+  private static DruidCluster tierOfPreloadedHistoricals(
+      Map<String, TestLoadQueuePeon> peonsOut,
+      Map<String, List<DataSegment>> nameToLoadedSegments
+  )
+  {
+    final DruidCluster.Builder cluster = DruidCluster.builder();
+    nameToLoadedSegments.forEach((name, loaded) -> {
+      final DruidServer server = server(name);
+      loaded.forEach(segment -> server.addDataSegment(segment, STALE_PROFILE));
+
+      final TestLoadQueuePeon peon = new TestLoadQueuePeon();
+      peonsOut.put(name, peon);
+      cluster.add(new ServerHolder(server.toImmutableDruidServer(), peon));
+    });
+    return cluster.build();
+  }
+
   private static ServerHolder historical(String name, String tier)
   {
     final DruidServer server =
@@ -246,6 +418,27 @@ public class RunRulesPartialLoadPlacementTest
     return new ServerHolder(server.toImmutableDruidServer(), new 
TestLoadQueuePeon());
   }
 
+  private static DruidServer server(String name)
+  {
+    return new DruidServer(name, name, null, 10L << 30, null, 
ServerType.HISTORICAL, TIER, 0);
+  }
+
+  /** One single-partition segment per day, all carrying the same tenant 
tuple. */
+  private static List<DataSegment> dailySegments(int count, String tenant)
+  {
+    final ClusterGroupTuples groups = new ClusterGroupTuples(
+        RowSignature.builder().add("tenant", ColumnType.STRING).build(),
+        List.of(Collections.singletonList(tenant))
+    );
+
+    final List<DataSegment> segments = new ArrayList<>(count);
+    for (int i = 0; i < count; i++) {
+      final Interval day = new Interval(CHUNK.getStart().plusDays(i), 
CHUNK.getStart().plusDays(i + 1));
+      segments.add(segment(day, new NumberedShardSpec(0, 1), groups));
+    }
+    return segments;
+  }
+
   private static DataSegment clusteredSegment(NumberedShardSpec shardSpec, 
String tenant)
   {
     final ClusterGroupTuples groups = new ClusterGroupTuples(
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
index a9cfb41bbc5..f6bd341fd86 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
@@ -615,6 +615,51 @@ public class StrategicSegmentAssignerPartialTest
     Assertions.assertNull(((TestLoadQueuePeon) 
decommServer.getPeon()).getProfileFor(segment));
   }
 
+  @Test
+  public void 
testCancelledStaleInFlightIsNotReloadedOntoDecommissioningServer()
+  {
+    // The tier's only server is decommissioning and carries a 
stale-fingerprint in-flight load. Cancelling that load
+    // frees its slot, but canLoadSegment still rejects the server, so nothing 
is queued back onto it.
+    final DataSegment segment = createSegment();
+    final PartialLoadProfile staleInFlightProfile = 
PartialLoadProfile.forRequest(
+        Map.of(
+            "type", "partialProjection",
+            "projections", List.of("users"),
+            "fingerprint", FP_USERS
+        ),
+        FP_USERS
+    );
+    final TestLoadQueuePeon peon = new TestLoadQueuePeon();
+    peon.addInFlightHolder(new SegmentHolder(
+        segment,
+        SegmentAction.LOAD,
+        staleInFlightProfile,
+        Duration.standardSeconds(10),
+        null
+    ));
+    final ServerHolder decommServer =
+        new ServerHolder(createDruidServer(TIER1).toImmutableDruidServer(), 
peon, true);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, 
decommServer).build();
+
+    final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster, 
segment);
+    params.getSegmentAssigner()
+          .replicateSegmentPartially(segment, profileForRevenue(), 
ImmutableMap.of(TIER1, 1));
+
+    final CoordinatorRunStats stats = params.getCoordinatorStats();
+    Assertions.assertEquals(
+        1L,
+        stats.getSegmentStat(Stats.Segments.PARTIAL_STALE_CANCELLED, TIER1, 
segment.getDataSource()),
+        "the stale in-flight load is still cancelled"
+    );
+    Assertions.assertEquals(
+        0L,
+        stats.getSegmentStat(Stats.Segments.PARTIAL_ASSIGNED, TIER1, 
segment.getDataSource()),
+        "no partial load is queued on a decommissioning server"
+    );
+    Assertions.assertTrue(decommServer.getLoadingSegments().isEmpty());
+    Assertions.assertNull(peon.getProfileFor(segment));
+  }
+
   @Test
   public void testStaleInFlightCancelledAndReplaced()
   {


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

Reply via email to