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

clintropolis 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 bbb161c37dd feat: partial load rules assignment prefer in-place reload 
over fresh load (#20267)
bbb161c37dd is described below

commit bbb161c37dd61e710c5a3cf89fed642701bd79b0
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Sep 11 13:03:13 2026 -0700

    feat: partial load rules assignment prefer in-place reload over fresh load 
(#20267)
---
 .../segment/loading/SegmentLocalCacheManager.java  | 182 ++++++++++++-----
 .../org/apache/druid/server/SegmentManager.java    |  34 +++-
 .../coordination/SegmentLoadDropHandler.java       |  13 +-
 .../druid/server/coordinator/ServerHolder.java     |  35 +++-
 .../loading/PartialSegmentStatusInTier.java        |  64 +++---
 .../loading/StrategicSegmentAssigner.java          | 115 ++++++++---
 .../segment/loading/NoopSegmentCacheManager.java   |   2 +-
 ...egmentLocalCacheManagerPartialRuleLoadTest.java | 161 ++++++++++++++-
 .../apache/druid/server/SegmentManagerTest.java    |  38 ++++
 .../coordination/SegmentLoadDropHandlerTest.java   |  90 +++++++++
 .../druid/server/coordinator/ServerHolderTest.java | 103 ++++++++++
 .../StrategicSegmentAssignerPartialTest.java       | 225 ++++++++++++++++++++-
 .../druid/test/utils/TestSegmentCacheManager.java  |  22 +-
 13 files changed, 951 insertions(+), 133 deletions(-)

diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
 
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
index 91fd583d4db..3a881615881 100644
--- 
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
+++ 
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
@@ -36,6 +36,7 @@ import org.apache.druid.java.util.common.FileUtils;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.ISE;
 import org.apache.druid.java.util.common.Stopwatch;
+import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.common.io.Closer;
 import org.apache.druid.java.util.emitter.EmittingLogger;
@@ -58,6 +59,7 @@ import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
@@ -1025,19 +1027,13 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
     final ReferenceCountingLock lock = lock(dataSegment);
     synchronized (lock) {
       try {
-        // If a stale non-partial cache entry sits at this segment id (a 
CompleteSegmentCacheEntry created by a prior
-        // acquireSegment while virtualStoragePartialDownloadsEnabled=false, 
for example), evict it before any
-        // partial-entry lookup or reservation; otherwise 
findExistingPartialWithHold's defensive type-check would
-        // throw, and reservePartial's addWeakReservationHold would land on 
the incompatible entry. If the stale
-        // entry is currently held (in-flight query), this throws a retryable 
SegmentLoadingException; the
-        // coordinator's load queue retries on next sync, and by then the 
query should have released.
-        evictStaleNonPartialWeakEntry(dataSegment.getId());
-
         if (rangeReader == null) {
           // Backend doesn't support range reads (e.g. zipped deep storage). 
The rule can't be honored as a partial
           // load; clear any prior rule so the segment falls through to the 
ordinary weak-full-load path at query
-          // time.
-          final ReservedPartial existing = 
findExistingPartialWithHold(dataSegment.getId());
+          // time. A non-partial entry carries no rule, so there is nothing to 
release for one.
+          final ReservedPartial existing = 
hasNonPartialEntry(dataSegment.getId())
+                                           ? null
+                                           : 
findExistingPartialWithHold(dataSegment.getId());
           if (existing != null) {
             try {
               // Snapshot the prior realized footprint before clearRule zeroes 
out ruleBundleHolds so the log can
@@ -1076,22 +1072,14 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
           return dataSegment;
         }
 
+        // Committed to attempting the rule now. If a stale non-partial cache 
entry sits at this segment id (a
+        // complete created by a prior acquireSegment while 
virtualStoragePartialDownloadsEnabled=false, for example),
+        // evict it before any partial-entry lookup or reservation.
+        evictStaleNonPartialWeakEntry(dataSegment.getId());
+
         final ReservedPartial reserved = findOrReservePartial(dataSegment, 
rangeReader);
         try {
           final PartialSegmentMetadataCacheEntry metadata = 
reserved.metadata();
-          // findOrReservePartial only invokes reservePartial (which writes 
the info file) on the fresh-reserve
-          // branch. On the find-existing branch the info file on disk still 
carries the PRIOR rule's wrapped
-          // load spec, so a rule swap here would apply in memory only. 
Rewrite unconditionally before mount.
-          try {
-            rewriteInfoFile(dataSegment);
-          }
-          catch (IOException e) {
-            throw new SegmentLoadingException(
-                e,
-                "Failed to write partial info file for segment[%s]",
-                dataSegment.getId()
-            );
-          }
           try {
             metadata.mount(reserved.location());
           }
@@ -1116,8 +1104,13 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
           final Set<String> selected = Set.copyOf(
               wrapper.getSelectedBundleNames(dataSegment, 
mapper.getSegmentFileMetadata())
           );
+          // Snapshot the rule this call is about to replace, then pin the 
UNION of it and the new selection for the
+          // duration of the attempt.
           final String priorFingerprint = metadata.getRuleFingerprint();
-          metadata.applyRule(wrapper.getFingerprint(), selected);
+          final Set<String> priorSelection = 
metadata.getRuleSelectedBundleNames();
+          final Set<String> attemptSelection = new HashSet<>(priorSelection);
+          attemptSelection.addAll(selected);
+          metadata.applyRule(wrapper.getFingerprint(), attemptSelection);
           if (priorFingerprint != null && 
!priorFingerprint.equals(wrapper.getFingerprint())) {
             log.info(
                 "Reconciled partial-load rule for segment[%s]: fingerprint 
transitioned [%s] → [%s]",
@@ -1126,11 +1119,17 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
                 wrapper.getFingerprint()
             );
           }
-          // Block until every eager download completes so the announcement 
fingerprint reflects reality: any failure
-          // clears the rule state (releasing self-hold + all bundle 
rule-holds) and propagates as a load failure so
-          // the coordinator's load queue can retry on its next sync. The 
announced fingerprint == "rule fully
-          // realized" contract stays intact.
-          awaitEagerDownloadsOrClearRule(dataSegment, metadata, selected);
+          // Block until every eager download completes, then narrow the union 
pinned above down to the new rule. Any
+          // failure rolls the rule state back to the prior rule and 
propagates as a load failure so the coordinator's
+          // load queue can retry on its next sync.
+          realizeRuleOrRestorePrior(
+              dataSegment,
+              metadata,
+              wrapper.getFingerprint(),
+              selected,
+              priorFingerprint,
+              priorSelection
+          );
           // Wrap the announcement with a DataSegmentAndLoadProfile carrying 
the historical's realized footprint AFTER
           // eager downloads finished. forAnnouncement reads the profile back 
via profileOf() and stamps its
           // loadedBytes + fingerprint.
@@ -1154,14 +1153,19 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
   }
 
   /**
-   * Submit eager-download tasks to the loading pool for every rule-selected 
bundle not yet registered with
-   * {@code metadata}, then block until every task completes. On any failure 
the rule state is cleared
-   * and a {@link SegmentLoadingException} is thrown so the caller treats the 
load as failed and retries.
+   * Completes the second half of a rule swap. The caller has pinned the union 
of the prior and new selections; this
+   * submits eager-download tasks to the loading pool for every bundle of the 
new selection that is not resident yet,
+   * blocks until they all finish, then commits by persisting the new wrapper 
to the segment's info file and narrowing
+   * the pin down to {@code selected}. On any failure it puts {@code 
priorFingerprint} / {@code priorSelection} back
+   * and throws {@link SegmentLoadingException} so the caller treats the load 
as failed and retries.
    */
-  private void awaitEagerDownloadsOrClearRule(
+  private void realizeRuleOrRestorePrior(
       DataSegment dataSegment,
       PartialSegmentMetadataCacheEntry metadata,
-      Set<String> selected
+      String fingerprint,
+      Set<String> selected,
+      @Nullable String priorFingerprint,
+      Set<String> priorSelection
   ) throws SegmentLoadingException
   {
     final List<Future<?>> pending = new ArrayList<>();
@@ -1236,13 +1240,61 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
       }
     }
 
-    if (firstFailure != null) {
-      // Any late-completing pool task that still succeeds after we've cleared 
the rule will call registerBundle →
-      // observing ruleSelectedBundleNames == {} and skipping the rule-hold 
acquire.
-      metadata.clearRule();
-      throw new SegmentLoadingException(
-          firstFailure,
-          "Failed eager download of rule-selected bundles for segment[%s]; 
cleared partial-load rule",
+    if (firstFailure == null) {
+      try {
+        rewriteInfoFile(dataSegment);
+        // every bundle the new rule wants is resident and pinned, so the 
prior rule's extras can go. Pure
+        // release, since the target selection is a subset of what is held.
+        metadata.applyRule(fingerprint, selected);
+        return;
+      }
+      catch (Throwable t) {
+        firstFailure = t;
+      }
+    }
+
+    restorePriorRule(dataSegment, metadata, priorFingerprint, priorSelection);
+    throw new SegmentLoadingException(
+        firstFailure,
+        "Failed to realize partial-load rule[fingerprint=%s] for segment[%s]; 
%s",
+        fingerprint,
+        dataSegment.getId(),
+        priorFingerprint == null
+        ? "cleared partial-load rule"
+        : StringUtils.format("restored prior partial-load 
rule[fingerprint=%s]", priorFingerprint)
+    );
+  }
+
+  /**
+   * Puts {@code metadata} back on the rule it held before a failed {@link 
#loadPartial} attempt, by releasing the
+   * holds that attempt acquired.
+   * <p>
+   * This restores the prior rule <em>exactly</em>, because the attempt never 
released a prior hold: the caller pinned
+   * the union of the two selections up front, so every bundle the prior rule 
wants is still held and
+   * {@link PartialSegmentMetadataCacheEntry#applyRule} has nothing to 
re-acquire here..
+   * <p>
+   * Failure to restore must not mask the download failure that got us here, 
so it is logged and swallowed. It is not
+   * expected: releasing a hold does not fail.
+   */
+  private void restorePriorRule(
+      DataSegment dataSegment,
+      PartialSegmentMetadataCacheEntry metadata,
+      @Nullable String priorFingerprint,
+      Set<String> priorSelection
+  )
+  {
+    try {
+      if (priorFingerprint == null) {
+        metadata.clearRule();
+      } else {
+        metadata.applyRule(priorFingerprint, priorSelection);
+      }
+    }
+    catch (Throwable t) {
+      log.warn(
+          t,
+          "Failed to restore prior partial-load rule[fingerprint=%s] on 
segment[%s] after a failed reload",
+          priorFingerprint,
           dataSegment.getId()
       );
     }
@@ -1608,10 +1660,9 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
    * reclaim of the partial state on disk is left to eviction, as it is for 
{@link #drop}.
    * <p>
    * The info file is rewritten before the rule is cleared, and a failed 
rewrite fails the load. Nothing is left half
-   * converted: releasing the holds cannot fail, and a load failure sends the 
historical down its drop path, which
-   * clears the rule and removes the info file, so there is no stale rule for 
a restart to reinstate. Leaving the rule
-   * applied and carrying on is not an option, because an unwrapped request 
announces as a full load either way, so the
-   * coordinator would record a replica with no profile and never ask again.
+   * converted, because a failed rewrite converts nothing at all: {@link 
#writeInfoFile} is atomic, so the info file
+   * still has the partial wrapper, and {@link 
PartialSegmentMetadataCacheEntry#clearRule} has not run, so the
+   * rule and every hold it owns are still in place.
    * <p>
    * Callers must hold this segment's {@link #lock(DataSegment)}, which is the 
external lock that
    * {@link PartialSegmentMetadataCacheEntry#clearRule} requires to be 
serialized against
@@ -1643,6 +1694,25 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
     );
   }
 
+  /**
+   * Whether any location holds a cache entry for {@code segmentId} that is 
<em>not</em> a
+   * {@link PartialSegmentMetadataCacheEntry}. Such an entry never carries a 
partial-load rule, and it is what
+   * {@link #evictStaleNonPartialWeakEntry} has to clear out of the way before 
a partial entry can be reserved at the
+   * same id. Callers that are not going to reserve one use this to skip the 
partial-entry lookup, whose defensive
+   * type-check would otherwise throw on it.
+   */
+  private boolean hasNonPartialEntry(SegmentId segmentId)
+  {
+    final SegmentCacheEntryIdentifier id = new 
SegmentCacheEntryIdentifier(segmentId);
+    for (StorageLocation location : locations) {
+      final CacheEntry entry = location.getCacheEntry(id);
+      if (entry != null && !(entry instanceof 
PartialSegmentMetadataCacheEntry)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /**
    * Whether any location already has a cache entry for {@code id}
    */
@@ -1775,8 +1845,13 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
    * Reapply the persisted partial-load rule to a bootstrap-restored metadata 
entry. Reads the wrapper from the
    * segment's info-file {@code loadSpec}, resolves the selected bundle names 
against the just-parsed on-disk
    * metadata header, calls {@link 
PartialSegmentMetadataCacheEntry#applyRule}, then drives eager downloads for any
-   * selected bundle that wasn't restored from disk (via {@link 
#awaitEagerDownloadsOrClearRule}). On failure the
+   * selected bundle that wasn't restored from disk (via {@link 
#realizeRuleOrRestorePrior}). On failure the
    * exception marks the segment as failed → doesn't announce it → the 
coordinator's next sync re-issues load.
+   * <p>
+   * A bootstrap-restored entry starts with no rule applied, so the union 
pinned here is just the new selection and
+   * the rollback degenerates to clearing the rule, which is the right 
starting state for a first application that
+   * failed. The prior state is read back rather than assumed so this stays 
correct if bootstrap ever restores a rule
+   * along with the entry.
    */
   private void reapplyRuleFromInfoFile(DataSegment dataSegment, 
PartialSegmentMetadataCacheEntry partial)
       throws SegmentLoadingException
@@ -1796,8 +1871,19 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
     final Set<String> selected = Set.copyOf(
         wrapper.getSelectedBundleNames(dataSegment, 
mapper.getSegmentFileMetadata())
     );
-    partial.applyRule(wrapper.getFingerprint(), selected);
-    awaitEagerDownloadsOrClearRule(dataSegment, partial, selected);
+    final String priorFingerprint = partial.getRuleFingerprint();
+    final Set<String> priorSelection = partial.getRuleSelectedBundleNames();
+    final Set<String> attemptSelection = new HashSet<>(priorSelection);
+    attemptSelection.addAll(selected);
+    partial.applyRule(wrapper.getFingerprint(), attemptSelection);
+    realizeRuleOrRestorePrior(
+        dataSegment,
+        partial,
+        wrapper.getFingerprint(),
+        selected,
+        priorFingerprint,
+        priorSelection
+    );
   }
 
   @Override
diff --git a/server/src/main/java/org/apache/druid/server/SegmentManager.java 
b/server/src/main/java/org/apache/druid/server/SegmentManager.java
index 73ab935a084..f8c43ec5646 100644
--- a/server/src/main/java/org/apache/druid/server/SegmentManager.java
+++ b/server/src/main/java/org/apache/druid/server/SegmentManager.java
@@ -141,6 +141,27 @@ public class SegmentManager
     return 
Optional.ofNullable(dataSources.get(dataSource.getName())).map(DataSourceState::getTimeline);
   }
 
+  /**
+   * Whether this server is already serving {@code dataSegment}, i.e. it is in 
its datasource's timeline and so is
+   * queryable right now. A load request for such a segment is a reload rather 
than a new load, which is how a
+   * partial-load rule is applied, swapped, or released, see {@code 
StrategicSegmentAssigner}.
+   * <p>
+   * This is a lock-free read of the timeline, unlike the {@code compute} that 
{@link #loadSegment} mutates it under,
+   * so it answers for the instant it is called and nothing more.
+   */
+  public boolean isSegmentLoaded(final DataSegment dataSegment)
+  {
+    final DataSourceState dataSourceState = 
dataSources.get(dataSegment.getDataSource());
+    if (dataSourceState == null) {
+      return false;
+    }
+    return dataSourceState.getTimeline().findChunk(
+        dataSegment.getInterval(),
+        dataSegment.getVersion(),
+        dataSegment.getShardSpec().getPartitionNum()
+    ) != null;
+  }
+
   /**
    * Given a list of {@link DataSegmentAndDescriptor} produce a {@link 
LeafSegmentsBundle} which partitions segments
    * into cached, loadable, or missing segments. This gives callers the 
flexibilty to decide to perform operations
@@ -320,19 +341,16 @@ public class SegmentManager
    *         {@link org.apache.druid.client.DataSegmentAndLoadProfile} 
wrapping it when the historical actually
    *         materialized a partial-load footprint. Callers pass the returned 
value to the announcement layer so
    *         partial-load announcements carry accurate {@code loadedBytes}.
+   * <b>Failure cleanup belongs to the caller.</b> This method deliberately 
discards nothing when the load fails,
+   * because it cannot tell on its own whether the state it would discard is 
half-materialized leftovers from this
+   * attempt or a live replica.
+   *
    * @throws SegmentLoadingException if the segment cannot be loaded
    * @throws IOException if the segment info cannot be cached on disk
    */
   public DataSegment loadSegment(final DataSegment dataSegment) throws 
SegmentLoadingException, IOException
   {
-    final DataSegment loaded;
-    try {
-      loaded = cacheManager.load(dataSegment);
-    }
-    catch (SegmentLoadingException e) {
-      cacheManager.drop(dataSegment);
-      throw e;
-    }
+    final DataSegment loaded = cacheManager.load(dataSegment);
     // Pass the plain dataSegment (not the potentially-wrapped `loaded`) to 
loadSegmentInternal: the wrapper is a
     // load-time announcement-path artifact only
     loadSegmentInternal(dataSegment);
diff --git 
a/server/src/main/java/org/apache/druid/server/coordination/SegmentLoadDropHandler.java
 
b/server/src/main/java/org/apache/druid/server/coordination/SegmentLoadDropHandler.java
index 1e024fcc5bb..50590bd275b 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordination/SegmentLoadDropHandler.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordination/SegmentLoadDropHandler.java
@@ -166,7 +166,18 @@ public class SegmentLoadDropHandler
         loaded = segmentManager.loadSegment(segment);
       }
       catch (Exception e) {
-        removeSegment(segment, DataSegmentChangeCallback.NOOP, false);
+        // decides whether to clean up a failed load; a load request for a 
segment this server already serves is a
+        // reload, not a new load, and we only want to discard the 
half-materialized state a failed *new* load leaves
+        // behind.
+        if (segmentManager.isSegmentLoaded(segment)) {
+          log.warn(
+              e,
+              "Failed to load segment[%s], but it is serving already; leaving 
it in place to be retried.",
+              segment.getId()
+          );
+        } else {
+          removeSegment(segment, DataSegmentChangeCallback.NOOP, false);
+        }
         throw new SegmentLoadingException(e, "Exception loading segment[%s]", 
segment.getId());
       }
       try {
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java 
b/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
index 60593175d5f..f6cfd263bb4 100644
--- a/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
+++ b/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
@@ -223,7 +223,9 @@ public class ServerHolder implements 
Comparable<ServerHolder>
    * The total size:
    * <ol>
    * <li>INCLUDES segments loaded on this server</li>
-   * <li>INCLUDES segments loading on this server (actions: 
LOAD/REPLICATE)</li>
+   * <li>INCLUDES segments loading on this server (actions: LOAD/REPLICATE). A 
load of a segment this server already
+   * serves is an in-place reload, and is counted at its {@link 
#inPlaceReloadSizeDelta} rather than its full size, so
+   * that the bytes already on disk are not counted twice</li>
    * <li>INCLUDES segments moving to this server (action: MOVE_TO)</li>
    * <li>INCLUDES segments moving from this server (action: MOVE_FROM). This is
    * because these segments have only been <i>marked</i> for drop. We include
@@ -515,8 +517,12 @@ public class ServerHolder implements 
Comparable<ServerHolder>
 
     // Add to projected if load is started, remove from projected if drop has 
started
     if (action.isLoad()) {
-      projectedSegmentCounts.addSegment(segment);
-      sizeOfLoadingSegments += segment.getSize();
+      if (hasSegmentLoaded(segment.getId())) {
+        sizeOfLoadingSegments += inPlaceReloadSizeDelta(segment);
+      } else {
+        projectedSegmentCounts.addSegment(segment);
+        sizeOfLoadingSegments += segment.getSize();
+      }
     } else {
       projectedSegmentCounts.removeSegment(segment);
       if (action == SegmentAction.DROP) {
@@ -532,8 +538,12 @@ public class ServerHolder implements 
Comparable<ServerHolder>
     queuedSegments.remove(segment);
 
     if (action.isLoad()) {
-      projectedSegmentCounts.removeSegment(segment);
-      sizeOfLoadingSegments -= segment.getSize();
+      if (hasSegmentLoaded(segment.getId())) {
+        sizeOfLoadingSegments -= inPlaceReloadSizeDelta(segment);
+      } else {
+        projectedSegmentCounts.removeSegment(segment);
+        sizeOfLoadingSegments -= segment.getSize();
+      }
     } else {
       projectedSegmentCounts.addSegment(segment);
       if (action == SegmentAction.DROP) {
@@ -542,6 +552,21 @@ public class ServerHolder implements 
Comparable<ServerHolder>
     }
   }
 
+  /**
+   * Upper bound of bytes an in-place reload of {@code segment} can still add 
to this server, i.e. a load queued on a
+   * server that is already serving it. This is an upper bound, not the exact 
delta, which the coordinator cannot know
+   * until the historical announces the footprint it realized.
+   * <p>
+   * Both the loaded set and the announced profile come from this run's 
immutable server snapshot, so the value is
+   * stable across the {@link #addToQueuedSegments} / {@link 
#removeFromQueuedSegments} pair for a canceled operation.
+   */
+  private long inPlaceReloadSizeDelta(DataSegment segment)
+  {
+    final PartialLoadProfile loaded = 
server.getPartialLoadProfile(segment.getId());
+    final Long loadedBytes = loaded == null ? null : loaded.loadedBytes();
+    return loadedBytes == null ? 0L : Math.max(0L, segment.getSize() - 
loadedBytes);
+  }
+
   @Override
   public int compareTo(ServerHolder serverHolder)
   {
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialSegmentStatusInTier.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialSegmentStatusInTier.java
index 84dd10f3dd4..22aa4251c97 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialSegmentStatusInTier.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialSegmentStatusInTier.java
@@ -41,13 +41,16 @@ import java.util.Objects;
  * fingerprint equals the requested fingerprint; rule is satisfied for that 
replica) vs "stale" (any other state,
  * including a non-profile regular full-load replica).
  * <p>
- * As a last resort, when a stale replica has nowhere better to be replaced, 
this classifies the same server as a
- * target for an "additive-historical" in-place replace: a partial-load 
request arriving at a server that's already
- * (stale-)loaded fills in the missing parts in place rather than 
re-downloading from scratch. That's what makes
- * {@link #getEligibleForAdditiveReload()} a safe fallback destination when 
the tier has no spare capacity. This
- * option is the least preferred because the contract of an additive reload is 
to load only what is now needed and
- * missing; it does not drop anything that is no longer needed, so the server 
can end up holding a larger amount of
- * data than the current rule strictly requires.
+ * A stale replica is also classified as a target for an in-place reload: a 
partial-load request arriving at a server
+ * that is already (stale-)loaded is honored by swapping the rule on the cache 
entry it already has, so only the delta
+ * the new fingerprint adds has to come off deep storage. That makes {@link 
#getEligibleForInPlaceReload()} the
+ * <em>preferred</em> destination for a matching deficit as it downloads 
strictly less than a fresh load elsewhere,
+ * the replica keeps serving throughout, and no follow-up drop is needed to 
retire the replica it replaces.
+ * <p>
+ * What makes this safe is that the historical pins a rule with cache holds 
rather than accumulating. Applying a rule
+ * releases the holds on every bundle the new fingerprint does not select, so 
a reloaded server ends up pinned by
+ * exactly the new rule; whatever the previous rule left on disk is ordinary 
evictable cache, reclaimed under pressure
+ * like any other unheld data, and it is not part of the footprint the server 
announces back.
  */
 public class PartialSegmentStatusInTier
 {
@@ -56,7 +59,7 @@ public class PartialSegmentStatusInTier
   private final List<ServerHolder> matchingInFlight = new ArrayList<>();
   private final List<ServerHolder> staleInFlight = new ArrayList<>();
   private final List<ServerHolder> eligibleForFreshLoad = new ArrayList<>();
-  private final List<ServerHolder> eligibleForAdditiveReload = new 
ArrayList<>();
+  private final List<ServerHolder> eligibleForInPlaceReload = new 
ArrayList<>();
 
   public PartialSegmentStatusInTier(
       DataSegment segment,
@@ -80,8 +83,8 @@ public class PartialSegmentStatusInTier
 
   /**
    * Servers that have the segment loaded but with a non-matching profile 
(different fingerprint, or no profile at all,
-   * i.e. a regular full-load replica seen against a partial rule). Eligible 
for additive reload (the historical
-   * fills in the missing parts in place) and for being dropped once enough 
matching replicas exist.
+   * i.e. a regular full-load replica seen against a partial rule). Eligible 
for an in-place reload onto the new
+   * fingerprint, and for being dropped once enough matching replicas exist.
    */
   public List<ServerHolder> getStaleLoaded()
   {
@@ -119,19 +122,19 @@ public class PartialSegmentStatusInTier
   }
 
   /**
-   * Stale-loaded servers that can take an additive reload request; kept as a 
subset of {@link #getStaleLoaded()}
-   * filtered for decommissioning / load-queue-full, so the algorithm can 
target them as a fallback destination when
-   * no fresh-load slots are available. See {@code 
StrategicSegmentAssigner.updateReplicasInTierPartial}.
+   * Stale-loaded servers that can take an in-place reload request: the subset 
of {@link #getStaleLoaded()} that passes
+   * {@link #canReloadInPlace}. The preferred destination for a matching 
deficit, ahead of
+   * {@link #getEligibleForFreshLoad()}. See {@link 
StrategicSegmentAssigner#updateReplicasInTierPartial}.
    */
-  public List<ServerHolder> getEligibleForAdditiveReload()
+  public List<ServerHolder> getEligibleForInPlaceReload()
   {
-    return eligibleForAdditiveReload;
+    return eligibleForInPlaceReload;
   }
 
   /**
    * Mechanical classification of one server against the request fingerprint. 
Branches are mutually exclusive in
    * order: <b>loaded</b> ({@link ServerHolder#isServingSegment}: matching / 
stale, with stale optionally also added
-   * to {@link #eligibleForAdditiveReload}), <b>in-flight 
LOAD/REPLICATE/MOVE_TO</b> (matching / stale based on the
+   * to {@link #eligibleForInPlaceReload}), <b>in-flight 
LOAD/REPLICATE/MOVE_TO</b> (matching / stale based on the
    * peon's queued profile), <b>empty-and-loadable</b> ({@link 
#eligibleForFreshLoad}).
    * <p>
    * A balancer move is counted at its destination: the {@link 
SegmentAction#MOVE_TO} carries the profile cloned from
@@ -146,7 +149,7 @@ public class PartialSegmentStatusInTier
    * Servers with a queued {@link SegmentAction#DROP} fall through all 
branches as well, they're accounted for in
    * {@link SegmentReplicaCount} totals and {@link StrategicSegmentAssigner}'s 
cross-tier drop budget.
    * The {@code isLoaded} branch is gated by {@link 
ServerHolder#isServingSegment}, which requires <em>no</em> action
-   * queued, so stale-loaded servers added to {@link 
#eligibleForAdditiveReload} are guaranteed to be action-free at
+   * queued, so stale-loaded servers added to {@link 
#eligibleForInPlaceReload} are guaranteed to be action-free at
    * snapshot time.
    */
   private void classify(ServerHolder server, DataSegment segment, String 
requestedFingerprint)
@@ -160,8 +163,8 @@ public class PartialSegmentStatusInTier
         matchingLoaded.add(server);
       } else {
         staleLoaded.add(server);
-        if (canReloadAdditively(server)) {
-          eligibleForAdditiveReload.add(server);
+        if (canReloadInPlace(server)) {
+          eligibleForInPlaceReload.add(server);
         }
       }
     } else if (action == SegmentAction.LOAD
@@ -179,18 +182,21 @@ public class PartialSegmentStatusInTier
   }
 
   /**
-   * Filters a stale-loaded server for additive-reload eligibility: not 
decommissioning, and not over its per-run
-   * load-queue budget. The "no other action queued" requirement that you'd 
otherwise expect to find here is
-   * already satisfied implicitly, this is only called from the {@code 
isLoaded} branch of {@link #classify}, which
-   * requires {@link ServerHolder#isServingSegment} = true (loaded AND no 
queued action). Same-run dedup against
-   * subsequent re-queueing on the same server is enforced at {@link 
ServerHolder#startOperation}, not here.
+   * Whether a server that already serves the segment can take an in-place 
reload of it: not decommissioning, and not
+   * over its per-run load-queue budget. Callers are responsible for 
establishing that the server actually serves the
+   * segment with no other action queued ({@link 
ServerHolder#isServingSegment}); {@link #classify} gets that from the
+   * {@code isLoaded} branch it calls this from. Same-run dedup against 
subsequent re-queueing on the same server is
+   * enforced at {@link ServerHolder#startOperation}, not here.
+   * <p>
+   * {@link ServerHolder#canLoadSegment} is not usable in its place because it 
requires the server to <em>not</em>
+   * already have the segment, which is precisely the case being handled here.
    * <p>
-   * Disk space is not checked: the additive reload's marginal cost is at most
-   * {@code segment.size − alreadyLoadedSize}, and a strict full-size disk 
check would over-conservatively block
-   * reloads on near-full servers that already host the stale replica. If the 
historical is too full to add the
-   * missing parts, the load fails at the historical and reports as failed; 
the reconciler retries next run.
+   * Disk space is not checked: the reload's marginal cost is at most {@code 
segment.size − alreadyLoadedSize}, and a
+   * strict full-size disk check would over-conservatively block reloads on 
near-full servers that already host the
+   * stale replica. If the historical is too full to add the missing parts, 
the load fails at the historical and
+   * reports as failed; the reconciler retries next run.
    */
-  private static boolean canReloadAdditively(ServerHolder server)
+  public static boolean canReloadInPlace(ServerHolder server)
   {
     return !server.isDecommissioning() && !server.isLoadQueueFull();
   }
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 89e51f99660..8691b34fe5e 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,7 +19,6 @@
 
 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;
@@ -346,7 +345,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
    * <h3>Algorithm</h3>
    * <ol>
    *   <li><b>Classify.</b> Build {@link PartialSegmentStatusInTier} for this 
tier; every server falls into at most
-   *       one of: matching-loaded, stale-loaded (optionally also 
eligible-for-additive-reload),
+   *       one of: matching-loaded, stale-loaded (optionally also 
eligible-for-in-place-reload),
    *       matching-in-flight, stale-in-flight, eligible-for-fresh-load, or 
unclassified (drop or move source
    *       pending; see {@link PartialSegmentStatusInTier#classify} for why). 
Matching means the announced
    *       fingerprint equals this request's fingerprint; stale is anything 
else, including a non-profile regular
@@ -356,17 +355,18 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
    *       needed, because the classification never counts both endpoints of a 
move.</li>
    *   <li><b>If matching count is short of {@code requiredReplicas}</b> 
(deficit):
    *     <ol type="a">
-   *       <li>Cancel stale-in-flight loads to free their slots. Canceled 
servers become same-run fresh-load
-   *           destinations.</li>
-   *       <li>Queue fresh partial-load requests up to the deficit. 
Destination preference order, applied in
+   *       <li>Cancel stale-in-flight loads to free their slots. Canceled 
servers become same-run load destinations.
+   *           </li>
+   *       <li>Queue matching partial-load requests up to the deficit. 
Destination preference order, applied in
    *           {@link #loadPartialReplicas}:
    *           <ol>
-   *             <li>Empty servers (clean slate, no in-place mutation 
needed).</li>
-   *             <li>Servers whose stale-in-flight load we just canceled in 
(a), their slot is now free.</li>
-   *             <li>Stale-loaded servers eligible for additive reload (the 
historical fills in the missing parts in
-   *                 place). This is the fallback path that mitigates the "no 
spare server" stuck state.
+   *             <li>Stale-loaded servers eligible for an in-place reload: the 
historical swaps the rule on the cache
+   *                 entry it already has, so only the delta the new 
fingerprint adds is downloaded, the replica
+   *                 keeps serving throughout, and the stale replica retires 
itself instead of needing a drop.
    *                 Same-run dedup is enforced by {@link 
ServerHolder#startOperation}, which rejects a second
    *                 queue attempt on a server whose segment is already 
queued.</li>
+   *             <li>Empty servers, and servers whose stale-in-flight load we 
just canceled in (a), ordered by the
+   *                 balancer strategy. These download the whole request from 
deep storage.</li>
    *           </ol>
    *       </li>
    *     </ol>
@@ -377,7 +377,9 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
    *       already meets the requirement. This preserves availability across 
the swap: stale replicas keep serving
    *       until matching replicas have completed loading and announced, then 
get dropped. The
    *       {@code maxReplicasToDrop} budget caps how many drops we queue per 
coordinator run to avoid drop
-   *       storms.</li>
+   *       storms. A stale replica this run just queued an in-place reload on 
is never dropped here even though the
+   *       classification snapshot still lists it as stale-loaded: reloads are 
only queued under a deficit, and a
+   *       deficit means matching-loaded is below the requirement, which is 
exactly what this gate tests.</li>
    * </ol>
    *
    * <h3>Returns</h3>
@@ -490,16 +492,30 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
   }
 
   /**
-   * 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).
+   * Queues matching partial-load requests on up to {@code numToLoad} eligible 
servers, preferring in-place reloads
+   * over fresh loads.
    * <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.
+   * A stale-loaded server already holds the segment's metadata plus whatever 
the previous rule pinned, and the
+   * historical honors a partial-load request there by swapping the rule on 
that cache entry: only the delta the new
+   * fingerprint adds comes off deep storage, the replica keeps serving 
throughout, and the replica it replaces is
+   * itself, so no follow-up drop is needed.
    * <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.
+   * The fresh-load candidates are the classifier's empty servers together 
with the {@code canceledStaleServers} that
+   * are left empty by the cancellation. {@link ServerHolder#cancelOperation} 
clears the queued action and restores the
+   * projected size, so those can take a fresh load; {@link 
#serversToLoadSegment} orders them by the balancer strategy
+   * (or round robin).
+   * <p>
+   * A reload the historical <em>fails</em> is retried in place on later runs 
rather than handed to a fresh server: the
+   * failure is asynchronous, so this run only ever learns that the request 
was queued, and the replica stays serving
+   * under its previous profile either way (see
+   * {@link 
org.apache.druid.server.coordination.SegmentLoadDropHandler#addSegment}), so 
the tier is never short a
+   * copy while it retries. Repeated failure does not need a fallback to break 
out of, because the way for one to
+   * persist is a historical too full to pin the new bundles, and that 
resolves itself: disk-usage balancing moves
+   * segments off the fullest server in the tier
+   * ({@code SegmentToMoveCalculator.computeSegmentsToMoveToBalanceDiskUsage}) 
until the reload fits, and a tier with
+   * nowhere left to move to is a cluster-wide capacity problem stalling far 
more than this one segment. Every failed
+   * attempt is alerted by the historical and logged by {@code 
HttpLoadQueuePeon.onRequestFailed}, so a retry loop
+   * that is not making progress is visible rather than silent.
    */
   private int loadPartialReplicas(
       int numToLoad,
@@ -517,25 +533,68 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
       return 0;
     }
 
-    // The classifier's list is already the complete candidate set when 
nothing was canceled.
+    // The classifier's lists are already the complete candidate sets when 
nothing was canceled.
+    final List<ServerHolder> inPlaceDestinations;
     final List<ServerHolder> freshCandidates;
     if (canceledStaleServers.isEmpty()) {
+      inPlaceDestinations = status.getEligibleForInPlaceReload();
       freshCandidates = status.getEligibleForFreshLoad();
     } else {
+      inPlaceDestinations = new 
ArrayList<>(status.getEligibleForInPlaceReload());
       freshCandidates = new ArrayList<>(status.getEligibleForFreshLoad());
-      freshCandidates.addAll(canceledStaleServers);
+      for (ServerHolder server : canceledStaleServers) {
+        if (server.isServingSegment(segment) && 
PartialSegmentStatusInTier.canReloadInPlace(server)) {
+          inPlaceDestinations.add(server);
+        } else {
+          freshCandidates.add(server);
+        }
+      }
     }
 
-    final Iterator<ServerHolder> destinations = Iterators.concat(
-        serversToLoadSegment(segment, tier, freshCandidates),
-        status.getEligibleForAdditiveReload().iterator()
+    int numLoadsQueued = queuePartialLoads(
+        numToLoad,
+        segment,
+        inPlaceDestinations.iterator(),
+        isAlreadyLoadedOnTier,
+        profile
     );
+    if (numLoadsQueued >= numToLoad) {
+      return numLoadsQueued;
+    }
 
-    if (!destinations.hasNext()) {
+    // Built only once the in-place reloads have fallen short: 
RoundRobinServerSelector advances its per-tier cursor
+    // past ineligible servers as soon as the iterator is constructed, so an 
unused one still perturbs placement.
+    final Iterator<ServerHolder> freshDestinations = 
serversToLoadSegment(segment, tier, freshCandidates);
+    if (inPlaceDestinations.isEmpty() && !freshDestinations.hasNext()) {
       incrementSkipStat(Stats.Segments.ASSIGN_SKIPPED, "No eligible server", 
segment, tier);
       return 0;
     }
 
+    return numLoadsQueued + queuePartialLoads(
+        numToLoad - numLoadsQueued,
+        segment,
+        freshDestinations,
+        isAlreadyLoadedOnTier,
+        profile
+    );
+  }
+
+  /**
+   * Queues a partial-load request carrying {@code profile} on up to {@code 
numToLoad} of the given destinations,
+   * stopping as soon as the deficit is covered. Servers that refuse the queue 
attempt (throttled, or a peon error)
+   * don't count towards {@code numToLoad}, so the next destination is tried 
in their place.
+   * <p>
+   * {@code destinations} is consumed lazily, {@link #serversToLoadSegment} 
returns iterators whose traversal has
+   * placement side effects.
+   */
+  private int queuePartialLoads(
+      int numToLoad,
+      DataSegment segment,
+      Iterator<ServerHolder> destinations,
+      boolean isAlreadyLoadedOnTier,
+      PartialLoadProfile profile
+  )
+  {
     int numLoadsQueued = 0;
     while (numLoadsQueued < numToLoad && destinations.hasNext()) {
       final ServerHolder server = destinations.next();
@@ -748,9 +807,8 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
    *   <li>their load queue is already at the configured {@code 
maxSegmentsInNodeLoadingQueue} budget for this run.</li>
    *   <li>they are decommissioning, since their replicas are on the way out 
and reloading them is wasted work.</li>
    * </ul>
-   * These are the same two eligibility conditions {@code 
PartialSegmentStatusInTier.canReloadAdditively} applies to
-   * the partial-load reconciler's in-place reload. {@link 
ServerHolder#canLoadSegment} is not usable here because it
-   * requires the server to <em>not</em> already have the segment, which is 
precisely the case being handled.
+   * The last two are {@link PartialSegmentStatusInTier#canReloadInPlace}, 
shared with the partial-load reconciler's
+   * in-place reload.
    */
   private int revertPartialProfileReplicas(DataSegment segment, String tier)
   {
@@ -771,8 +829,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
   private boolean revertPartialProfileReplica(DataSegment segment, 
ServerHolder server)
   {
     return server.isServingSegment(segment)
-           && !server.isDecommissioning()
-           && !server.isLoadQueueFull()
+           && PartialSegmentStatusInTier.canReloadInPlace(server)
            && server.getServer().getPartialLoadProfile(segment.getId()) != null
            && loadQueueManager.loadSegment(segment, server, 
SegmentAction.LOAD, null);
   }
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/NoopSegmentCacheManager.java
 
b/server/src/test/java/org/apache/druid/segment/loading/NoopSegmentCacheManager.java
index 2d9cfc39de3..b45ded107f4 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/NoopSegmentCacheManager.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/NoopSegmentCacheManager.java
@@ -71,7 +71,7 @@ public class NoopSegmentCacheManager implements 
SegmentCacheManager
   }
 
   @Override
-  public DataSegment load(DataSegment segment)
+  public DataSegment load(DataSegment segment) throws SegmentLoadingException
   {
     throw new UnsupportedOperationException();
   }
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
index b3be2adaa37..587f5280a38 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
@@ -67,6 +67,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
+import javax.annotation.Nullable;
 import java.io.File;
 import java.io.IOException;
 import java.util.Arrays;
@@ -754,6 +755,39 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     Assertions.assertNull(manager.getRuleFingerprintForSegment(SEGMENT_ID));
   }
 
+  @Test
+  void testRangeReaderNullDoesNotDisturbANonPartialEntry() throws Exception
+  {
+    // A non-partial cache entry sits at this segment id and the coordinator 
asks for a rule this historical cannot
+    // honor. Giving up on the rule must leave that entry alone: it is not 
going to be replaced by a partial one, so
+    // there is nothing to clear out of the way, and discarding its cached 
data would buy nothing. Evicting it used
+    // to be attempted before the range-reader check, which both destroyed 
cache for nothing when the entry was
+    // unheld and failed the load outright when it was held, as it is here.
+    manager = makeManager(true, true);
+    final StorageLocation location = manager.getLocations().get(0);
+    final SegmentCacheEntryIdentifier id = new 
SegmentCacheEntryIdentifier(SEGMENT_ID);
+
+    final DataSegment noRangeReader =
+        partialWrapperSegmentWithNullRangeReader(List.of(AGG_BUNDLE), 
"v1:cannot-honor");
+    // An on-demand acquire of a segment whose load spec cannot range-read 
registers a non-partial entry, held for
+    // as long as the action is open.
+    final AcquireSegmentAction inFlightQuery = 
manager.acquireSegment(noRangeReader, AcquireMode.PARTIAL);
+    try {
+      Assertions.assertNotNull(
+          location.getCacheEntry(id),
+          "precondition: an in-flight on-demand acquire holds a non-partial 
entry"
+      );
+
+      manager.load(noRangeReader);
+
+      Assertions.assertNotNull(location.getCacheEntry(id), "the non-partial 
entry is untouched");
+      Assertions.assertNull(manager.getRuleFingerprintForSegment(SEGMENT_ID), 
"and no rule is applied");
+    }
+    finally {
+      inFlightQuery.close();
+    }
+  }
+
   @Test
   void testLoadFailureLeavesNoRuleApplied() throws Exception
   {
@@ -781,6 +815,117 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     );
   }
 
+  @Test
+  void testFailedEagerDownloadRestoresThePriorRule() throws Exception
+  {
+    // A reload whose eager downloads fail must put the PRIOR rule back rather 
than clear the rule outright. The
+    // historical keeps serving the replica and keeps announcing the prior 
profile (the failed load never announces a
+    // new one), so the coordinator has to still find that profile's bundles 
pinned; clearing would leave the replica
+    // advertising a footprint it no longer holds, with every bundle of it 
evictable.
+    final StorageLoadingThreadPool loadingPool = 
StorageLoadingThreadPool.createFromConfig(
+        SegmentLoaderConfig.builder()
+                           .locations(List.of(new 
StorageLocationConfig(cacheRoot, 1024L * 1024L * 1024L, null)))
+                           .virtualStorage(true)
+                           .virtualStoragePartialDownloadsEnabled(true)
+                           .build()
+    );
+    manager = makeManagerAtLocations(true, true, List.of(cacheRoot), 
loadingPool);
+    final StorageLocation location = manager.getLocations().get(0);
+
+    manager.load(partialWrapperSegment(List.of(AGG_BUNDLE), 
"v1:rule-original"));
+    final PartialSegmentMetadataCacheEntry meta = 
weakReservedMetadata(location, SEGMENT_ID);
+    Assertions.assertEquals("v1:rule-original", meta.getRuleFingerprint());
+    Assertions.assertTrue(meta.isBundleRuleHeld(AGG_BUNDLE));
+
+    // Stopping the loading pool makes every eager download for the new rule's 
bundle fail to even be submitted.
+    loadingPool.stop();
+
+    Assertions.assertThrows(
+        SegmentLoadingException.class,
+        () -> manager.load(partialWrapperSegment(List.of(OTHER_AGG_BUNDLE), 
"v2:rule-updated"))
+    );
+
+    Assertions.assertEquals(
+        "v1:rule-original",
+        manager.getRuleFingerprintForSegment(SEGMENT_ID),
+        "a failed reload must restore the prior rule's fingerprint, not clear 
it"
+    );
+    Assertions.assertTrue(
+        meta.isBundleRuleHeld(AGG_BUNDLE),
+        "the prior rule's bundle must still be pinned after the failed reload"
+    );
+    Assertions.assertTrue(
+        location.isWeakReserved(new 
PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE)),
+        "the prior rule's bundle must never have been unreserved during the 
attempt, so it was never evictable"
+    );
+    Assertions.assertFalse(
+        meta.isBundleRuleHeld(OTHER_AGG_BUNDLE),
+        "the attempted rule's bundle must not be left pinned"
+    );
+  }
+
+  @Test
+  void testRestartAfterFailedReloadReappliesThePriorRuleNotTheFailedOne() 
throws Exception
+  {
+    // The durable half of the rollback. A failed reload restores the prior 
rule in memory and the replica keeps
+    // announcing it, so the info file has to still describe that rule too. If 
it described the rule that just
+    // failed, a restart would reapply it from disk, and a second failure 
there takes the replica down entirely:
+    // SegmentManager.loadSegmentOnBootstrap drops on failure and the segment 
never gets announced.
+    final StorageLoadingThreadPool loadingPool = 
StorageLoadingThreadPool.createFromConfig(
+        SegmentLoaderConfig.builder()
+                           .locations(List.of(new 
StorageLocationConfig(cacheRoot, 1024L * 1024L * 1024L, null)))
+                           .virtualStorage(true)
+                           .virtualStoragePartialDownloadsEnabled(true)
+                           .build()
+    );
+    final SegmentLocalCacheManager beforeRestart =
+        makeManagerAtLocations(true, true, List.of(cacheRoot), loadingPool);
+    try {
+      beforeRestart.load(partialWrapperSegment(List.of(AGG_BUNDLE), 
"v1:rule-original"));
+      Assertions.assertEquals("v1:rule-original", 
beforeRestart.getRuleFingerprintForSegment(SEGMENT_ID));
+
+      loadingPool.stop();
+      Assertions.assertThrows(
+          SegmentLoadingException.class,
+          () -> 
beforeRestart.load(partialWrapperSegment(List.of(OTHER_AGG_BUNDLE), 
"v2:rule-updated"))
+      );
+      Assertions.assertEquals(
+          "v1:rule-original",
+          beforeRestart.getRuleFingerprintForSegment(SEGMENT_ID),
+          "precondition: the failed reload rolled the in-memory rule back"
+      );
+    }
+    finally {
+      beforeRestart.shutdown();
+    }
+
+    // Restart over the same cache directory.
+    manager = makeManager(true, true);
+    final List<DataSegment> cached = manager.getCachedSegments();
+    final DataSegment rediscovered = cached.stream()
+                                           .filter(s -> 
s.getId().equals(SEGMENT_ID))
+                                           .findFirst()
+                                           .orElseThrow();
+    Assertions.assertEquals(
+        "v1:rule-original",
+        rediscovered.getLoadSpec().get("fingerprint"),
+        "the persisted load spec must describe the rule the replica is serving 
under, not the failed one"
+    );
+
+    manager.bootstrap(rediscovered, SegmentLazyLoadFailCallback.NOOP);
+    Assertions.assertEquals(
+        "v1:rule-original",
+        manager.getRuleFingerprintForSegment(SEGMENT_ID),
+        "bootstrap must reapply the prior rule, not the one that failed"
+    );
+    Assertions.assertTrue(
+        manager.getLocations().get(0).isWeakReserved(
+            new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, 
AGG_BUNDLE)
+        ),
+        "and pin that rule's bundle"
+    );
+  }
+
   @Test
   void testBootstrapReinstallsRuleHoldsFromPersistedInfoFile() throws Exception
   {
@@ -937,6 +1082,20 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
       boolean partialDownloadsEnabled,
       List<File> locationRoots
   )
+  {
+    return makeManagerAtLocations(virtualStorage, partialDownloadsEnabled, 
locationRoots, null);
+  }
+
+  /**
+   * @param loadingPool the loading pool to use, or null to build one from the 
config. Pass one in to keep a handle on
+   *                    it, e.g. to {@link StorageLoadingThreadPool#stop()} it 
mid-test and make eager downloads fail.
+   */
+  private SegmentLocalCacheManager makeManagerAtLocations(
+      boolean virtualStorage,
+      boolean partialDownloadsEnabled,
+      List<File> locationRoots,
+      @Nullable StorageLoadingThreadPool loadingPool
+  )
   {
     final List<StorageLocationConfig> locConfigs = locationRoots.stream()
         .map(root -> new StorageLocationConfig(root, 1024L * 1024L * 1024L, 
null))
@@ -950,7 +1109,7 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     return new SegmentLocalCacheManager(
         storageLocations,
         loaderConfig,
-        StorageLoadingThreadPool.createFromConfig(loaderConfig),
+        loadingPool == null ? 
StorageLoadingThreadPool.createFromConfig(loaderConfig) : loadingPool,
         new LeastBytesUsedStorageLocationSelectorStrategy(storageLocations),
         TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT),
         jsonMapper
diff --git 
a/server/src/test/java/org/apache/druid/server/SegmentManagerTest.java 
b/server/src/test/java/org/apache/druid/server/SegmentManagerTest.java
index 9a06d4d3a79..0b8a908547d 100644
--- a/server/src/test/java/org/apache/druid/server/SegmentManagerTest.java
+++ b/server/src/test/java/org/apache/druid/server/SegmentManagerTest.java
@@ -55,6 +55,7 @@ import org.apache.druid.segment.loading.StorageLocation;
 import org.apache.druid.segment.loading.StorageLocationConfig;
 import org.apache.druid.server.SegmentManager.DataSourceState;
 import org.apache.druid.server.metrics.NoopServiceEmitter;
+import org.apache.druid.test.utils.TestSegmentCacheManager;
 import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.apache.druid.testing.TemporaryFolderExtension;
 import org.apache.druid.timeline.DataSegment;
@@ -309,6 +310,43 @@ public class SegmentManagerTest extends 
InitializedNullHandlingTest
     assertResult(SEGMENTS);
   }
 
+  @Test
+  public void testFailedReloadDoesNotDropTheCachedSegment() throws 
SegmentLoadingException, IOException
+  {
+    final TestSegmentCacheManager failingCacheManager = new 
TestSegmentCacheManager();
+    failingCacheManager.failLoadsAfter(1);
+    final SegmentManager manager = new SegmentManager(failingCacheManager);
+    final DataSegment segment = SEGMENTS.get(0);
+
+    manager.loadSegment(segment);
+    Assertions.assertTrue(manager.isSegmentLoaded(segment));
+
+    Assertions.assertThrows(SegmentLoadingException.class, () -> 
manager.loadSegment(segment));
+
+    Assertions.assertTrue(manager.isSegmentLoaded(segment), "the replica stays 
in the timeline");
+    Assertions.assertFalse(
+        
failingCacheManager.getObservedSegmentsRemovedFromCache().contains(segment.getId()),
+        "a failed reload must not drop the live replica's cached data"
+    );
+  }
+
+  @Test
+  public void testFailedFirstLoadLeavesCleanupToTheCaller()
+  {
+    final TestSegmentCacheManager failingCacheManager = new 
TestSegmentCacheManager();
+    failingCacheManager.failLoadsAfter(0);
+    final SegmentManager manager = new SegmentManager(failingCacheManager);
+    final DataSegment segment = SEGMENTS.get(0);
+
+    Assertions.assertThrows(SegmentLoadingException.class, () -> 
manager.loadSegment(segment));
+
+    Assertions.assertFalse(manager.isSegmentLoaded(segment), "a failed load 
adds nothing to the timeline");
+    Assertions.assertFalse(
+        
failingCacheManager.getObservedSegmentsRemovedFromCache().contains(segment.getId()),
+        "and drops nothing either; SegmentLoadDropHandler.addSegment owns that 
decision"
+    );
+  }
+
   @Test
   public void testLoadDuplicatedSegmentsInParallel()
       throws ExecutionException, InterruptedException
diff --git 
a/server/src/test/java/org/apache/druid/server/coordination/SegmentLoadDropHandlerTest.java
 
b/server/src/test/java/org/apache/druid/server/coordination/SegmentLoadDropHandlerTest.java
index cf05c9a75da..13ae7ee19d3 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordination/SegmentLoadDropHandlerTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordination/SegmentLoadDropHandlerTest.java
@@ -187,6 +187,96 @@ public class SegmentLoadDropHandlerTest
     );
   }
 
+  @Test
+  public void testFailedReloadKeepsTheSegmentServing()
+  {
+    final TestSegmentCacheManager cacheManager = new TestSegmentCacheManager();
+    cacheManager.failLoadsAfter(1);
+    final SegmentManager segmentManager = new SegmentManager(cacheManager);
+    final SegmentLoadDropHandler handler = 
initSegmentLoadDropHandler(segmentManager);
+
+    final DataSegment segment = makeSegment("test", "1", 
Intervals.of("P1d/2011-04-01"));
+
+    handler.addSegment(segment, DataSegmentChangeCallback.NOOP, null);
+    Assertions.assertTrue(segmentManager.isSegmentLoaded(segment));
+    
Assertions.assertTrue(segmentAnnouncer.getObservedSegments().contains(segment));
+
+    // Second request for the same segment is a reload, and this cache manager 
fails it.
+    handler.addSegment(segment, DataSegmentChangeCallback.NOOP, null);
+    for (Runnable runnable : scheduledRunnable) {
+      runnable.run();
+    }
+
+    Assertions.assertTrue(
+        segmentManager.isSegmentLoaded(segment),
+        "a failed reload must leave the replica in the timeline, still 
queryable"
+    );
+    Assertions.assertTrue(
+        segmentAnnouncer.getObservedSegments().contains(segment),
+        "a failed reload must not unannounce the replica, the coordinator 
still counts it and asks again"
+    );
+    Assertions.assertFalse(
+        
cacheManager.getObservedSegmentsRemovedFromCache().contains(segment.getId()),
+        "a failed reload must not drop the replica's cached data"
+    );
+  }
+
+  @Test
+  public void testFailedFirstLoadIsStillCleanedUp()
+  {
+    final TestSegmentCacheManager cacheManager = new TestSegmentCacheManager();
+    cacheManager.failLoadsAfter(0);
+    final SegmentManager segmentManager = new SegmentManager(cacheManager);
+    final SegmentLoadDropHandler handler = 
initSegmentLoadDropHandler(segmentManager);
+
+    final DataSegment segment = makeSegment("test", "1", 
Intervals.of("P1d/2011-04-01"));
+
+    handler.addSegment(segment, DataSegmentChangeCallback.NOOP, null);
+    for (Runnable runnable : scheduledRunnable) {
+      runnable.run();
+    }
+
+    Assertions.assertFalse(segmentManager.isSegmentLoaded(segment));
+    
Assertions.assertFalse(segmentAnnouncer.getObservedSegments().contains(segment));
+    Assertions.assertTrue(
+        
cacheManager.getObservedSegmentsRemovedFromCache().contains(segment.getId()),
+        "a failed new load is cleaned up"
+    );
+  }
+
+  @Test
+  public void testFailedLoadPreservesAReplicaThatAppearedWhileItWasLoading()
+  {
+    final TestSegmentCacheManager cacheManager = new TestSegmentCacheManager();
+    final SegmentManager segmentManager = new SegmentManager(cacheManager);
+    final SegmentLoadDropHandler handler = 
initSegmentLoadDropHandler(segmentManager);
+
+    final DataSegment segment = makeSegment("test", "1", 
Intervals.of("P1d/2011-04-01"));
+
+    // Stand in for the concurrent loader: the segment lands in the timeline, 
then this load fails. A snapshot taken
+    // before the load would have said "not serving" and torn the replica down.
+    cacheManager.failLoadsAfter(1);
+    handler.addSegment(segment, DataSegmentChangeCallback.NOOP, null);
+    Assertions.assertTrue(segmentManager.isSegmentLoaded(segment), 
"precondition: the replica is serving");
+    segmentAnnouncer.getObservedSegments().clear();
+    segmentAnnouncer.announceSegment(segment);
+
+    handler.addSegment(segment, DataSegmentChangeCallback.NOOP, null);
+    for (Runnable runnable : scheduledRunnable) {
+      runnable.run();
+    }
+
+    Assertions.assertTrue(segmentManager.isSegmentLoaded(segment), "the 
serving replica stays in the timeline");
+    Assertions.assertTrue(
+        segmentAnnouncer.getObservedSegments().contains(segment),
+        "and stays announced"
+    );
+    Assertions.assertFalse(
+        
cacheManager.getObservedSegmentsRemovedFromCache().contains(segment.getId()),
+        "and keeps its cached data"
+    );
+  }
+
   @Test
   @Timeout(value = 60_000L, unit = TimeUnit.MILLISECONDS, threadMode = 
ThreadMode.SEPARATE_THREAD)
   public void testProcessBatch() throws Exception
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/ServerHolderTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/ServerHolderTest.java
index 3375da17882..833f7aa0ad9 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/ServerHolderTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/ServerHolderTest.java
@@ -21,17 +21,22 @@ package org.apache.druid.server.coordinator;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import org.apache.druid.client.DruidServer;
 import org.apache.druid.client.ImmutableDruidDataSource;
 import org.apache.druid.client.ImmutableDruidServer;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.server.coordination.DruidServerMetadata;
 import org.apache.druid.server.coordination.ServerType;
+import org.apache.druid.server.coordinator.loading.PartialLoadProfile;
+import org.apache.druid.server.coordinator.loading.SegmentAction;
+import org.apache.druid.server.coordinator.loading.SegmentLoadQueueManager;
 import org.apache.druid.server.coordinator.loading.TestLoadQueuePeon;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.partition.NoneShardSpec;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import javax.annotation.Nullable;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -68,6 +73,19 @@ public class ServerHolderTest
       "src2", new ImmutableDruidDataSource("src2", Collections.emptyMap(), 
Collections.singletonList(SEGMENTS.get(1)))
   );
 
+  private static final long SEGMENT_SIZE = 1000L;
+
+  private static final Map<String, Object> PARTIAL_LOAD_SPEC =
+      ImmutableMap.of("type", "partialProjection", "fingerprint", "v1:abc");
+
+  /**
+   * Non-zero sized counterparts of {@link #SEGMENTS}, for the projection 
accounting tests.
+   */
+  private static final List<DataSegment> SIZED_SEGMENTS = ImmutableList.of(
+      DataSegment.builder(SEGMENTS.get(0)).size(SEGMENT_SIZE).build(),
+      DataSegment.builder(SEGMENTS.get(1)).size(SEGMENT_SIZE).build()
+  );
+
   @Test
   public void testCompareTo()
   {
@@ -195,4 +213,89 @@ public class ServerHolderTest
     Assertions.assertFalse(h1.isServingSegment(SEGMENTS.get(1)));
     Assertions.assertFalse(h1.isLoadQueueFull());
   }
+
+  @Test
+  public void testLoadOfAbsentSegmentProjectsItsFullSize()
+  {
+    final ServerHolder holder = holderServing(null);
+    final long sizeUsedBefore = holder.getSizeUsed();
+    final int projectedCountBefore = 
holder.getProjectedSegmentCounts().getTotalSegmentCount();
+
+    Assertions.assertTrue(holder.startOperation(SegmentAction.LOAD, 
SIZED_SEGMENTS.get(1)));
+
+    Assertions.assertEquals(sizeUsedBefore + SEGMENT_SIZE, 
holder.getSizeUsed());
+    Assertions.assertEquals(
+        projectedCountBefore + 1,
+        holder.getProjectedSegmentCounts().getTotalSegmentCount()
+    );
+  }
+
+  @Test
+  public void testInPlaceReloadOfPartialReplicaProjectsOnlyTheDelta()
+  {
+    // A partial replica announces its realized footprint as curr_size, so 
reloading it in place can add at most the
+    // rest of the segment. Counting the whole segment again would double 
count the bytes already on disk.
+    final ServerHolder holder = 
holderServing(PartialLoadProfile.forLoaded(PARTIAL_LOAD_SPEC, "v1:abc", 400L));
+    final long sizeUsedBefore = holder.getSizeUsed();
+    final int projectedCountBefore = 
holder.getProjectedSegmentCounts().getTotalSegmentCount();
+
+    Assertions.assertTrue(holder.startOperation(SegmentAction.LOAD, 
SIZED_SEGMENTS.get(0)));
+
+    Assertions.assertEquals(sizeUsedBefore + (SEGMENT_SIZE - 400L), 
holder.getSizeUsed());
+    Assertions.assertEquals(
+        projectedCountBefore,
+        holder.getProjectedSegmentCounts().getTotalSegmentCount(),
+        "an in-place reload refreshes a replica that is already projected, it 
does not add one"
+    );
+  }
+
+  @Test
+  public void testInPlaceReloadOfFullReplicaProjectsNothing()
+  {
+    // A replica with no profile is a regular full load that already holds the 
whole segment, so applying a
+    // partial-load rule to it (or reverting it back to a plain load spec) 
cannot add any bytes.
+    final ServerHolder holder = holderServing(null);
+    final long sizeUsedBefore = holder.getSizeUsed();
+
+    Assertions.assertTrue(holder.startOperation(SegmentAction.LOAD, 
SIZED_SEGMENTS.get(0)));
+
+    Assertions.assertEquals(sizeUsedBefore, holder.getSizeUsed());
+    Assertions.assertEquals(1, 
holder.getProjectedSegmentCounts().getTotalSegmentCount());
+  }
+
+  @Test
+  public void testCancellingAnInPlaceReloadRestoresTheProjection()
+  {
+    // add/remove have to agree on the delta, otherwise a cancelled reload 
leaves the server's projection skewed for
+    // the rest of the run.
+    final ServerHolder holder = 
holderServing(PartialLoadProfile.forLoaded(PARTIAL_LOAD_SPEC, "v1:abc", 400L));
+    final long sizeUsedBefore = holder.getSizeUsed();
+    final int projectedCountBefore = 
holder.getProjectedSegmentCounts().getTotalSegmentCount();
+
+    // Queue through the load queue manager rather than startOperation 
directly, so that the peon holds the segment
+    // and can accept the cancellation.
+    Assertions.assertTrue(
+        new SegmentLoadQueueManager(null, null)
+            .loadSegment(SIZED_SEGMENTS.get(0), holder, SegmentAction.LOAD, 
null)
+    );
+    Assertions.assertTrue(holder.cancelOperation(SegmentAction.LOAD, 
SIZED_SEGMENTS.get(0)));
+
+    Assertions.assertEquals(sizeUsedBefore, holder.getSizeUsed());
+    Assertions.assertEquals(
+        projectedCountBefore,
+        holder.getProjectedSegmentCounts().getTotalSegmentCount()
+    );
+  }
+
+  /**
+   * A historical serving {@code SIZED_SEGMENTS.get(0)}, announced with {@code 
profile} when it is non-null so that the
+   * server's curr_size reflects a partial footprint rather than the whole 
segment.
+   */
+  private static ServerHolder holderServing(@Nullable PartialLoadProfile 
profile)
+  {
+    final DruidServer server =
+        new DruidServer("name1", "host1", null, 10_000L, null, 
ServerType.HISTORICAL, "tier1", 0);
+    server.addDataSegment(SIZED_SEGMENTS.get(0), profile);
+    return new ServerHolder(server.toImmutableDruidServer(), new 
TestLoadQueuePeon());
+  }
 }
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 f6bd341fd86..74b0e1582d1 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
@@ -495,9 +495,10 @@ public class StrategicSegmentAssignerPartialTest
   @Test
   public void testFullLoadReplicaTreatedAsStaleAgainstPartialRule()
   {
-    // s1 holds the segment as a regular full-load (no profile). Under a 
partial rule it counts as stale: queue a
-    // fresh load to satisfy the partial rule, but don't drop s1 yet; stale 
stays serving until the matching load
-    // completes. Note: s1 is also reload-eligible (additive), but because s2 
is empty, s2 is preferred.
+    // s1 holds the segment as a regular full-load (no profile). Under a 
partial rule it counts as stale, and it is
+    // still an in-place candidate: under virtual storage a queried full-load 
replica is already backed by a partial
+    // cache entry that on-demand reads populated, so applying the rule there 
pins bundles that are largely resident
+    // and avoids both a full download onto the empty s2 and the later drop of 
s1.
     final DataSegment segment = createSegment();
     final ServerHolder s1 = createServerWithLoaded(TIER1, segment, null);
     final ServerHolder s2 = createServer(TIER1);
@@ -513,11 +514,183 @@ public class StrategicSegmentAssignerPartialTest
         stats.hasStat(Stats.Segments.PARTIAL_STALE_DROPPED),
         "Stale must not be dropped before matching has actually loaded"
     );
-    Assertions.assertTrue(s2.getLoadingSegments().contains(segment));
-    Assertions.assertEquals(profileForRevenue(), ((TestLoadQueuePeon) 
s2.getPeon()).getProfileFor(segment));
+    Assertions.assertEquals(
+        profileForRevenue(),
+        ((TestLoadQueuePeon) s1.getPeon()).getProfileFor(segment),
+        "the profileless stale replica is reloaded in place under the rule's 
fingerprint"
+    );
+    Assertions.assertTrue(s2.getLoadingSegments().isEmpty(), "no fresh 
download is needed on the empty server");
     Assertions.assertTrue(s1.getPeon().getSegmentsToDrop().isEmpty());
   }
 
+  @Test
+  public void testInPlaceReloadPreferredOverFreshLoadOnEmptyServer()
+  {
+    // Same preference, but with a stale *partial* replica rather than a 
full-load one, and with more empty servers
+    // than the deficit: the only thing queued is the in-place reload on the 
server that already holds a footprint.
+    final DataSegment segment = createSegment();
+    final PartialLoadProfile usersProfile = PartialLoadProfile.forLoaded(
+        Map.of("type", "partialProjection", "projections", List.of("users"), 
"fingerprint", FP_USERS),
+        FP_USERS,
+        512L
+    );
+    final ServerHolder stale = createServerWithLoaded(TIER1, segment, 
usersProfile);
+    final ServerHolder empty1 = createServer(TIER1);
+    final ServerHolder empty2 = createServer(TIER1);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, stale, 
empty1, empty2).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_ASSIGNED, TIER1, 
segment.getDataSource()));
+    Assertions.assertEquals(profileForRevenue(), ((TestLoadQueuePeon) 
stale.getPeon()).getProfileFor(segment));
+    Assertions.assertTrue(empty1.getLoadingSegments().isEmpty());
+    Assertions.assertTrue(empty2.getLoadingSegments().isEmpty());
+  }
+
+  @Test
+  public void testInPlaceReloadsFirstThenFreshLoadsCoverTheRest()
+  {
+    // Deficit of 2 with only one stale replica to reload in place: the 
in-place reload covers one, and a fresh load
+    // on an empty server covers the other. The second empty server stays 
untouched.
+    final DataSegment segment = createSegment();
+    final ServerHolder stale = createServerWithLoaded(TIER1, segment, 
staleProfileForUsers());
+    final ServerHolder empty1 = createServer(TIER1);
+    final ServerHolder empty2 = createServer(TIER1);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, stale, 
empty1, empty2).build();
+
+    final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster, 
segment);
+    params.getSegmentAssigner()
+          .replicateSegmentPartially(segment, profileForRevenue(), 
ImmutableMap.of(TIER1, 2));
+
+    final CoordinatorRunStats stats = params.getCoordinatorStats();
+    Assertions.assertEquals(2L, 
stats.getSegmentStat(Stats.Segments.PARTIAL_ASSIGNED, TIER1, 
segment.getDataSource()));
+    Assertions.assertEquals(profileForRevenue(), ((TestLoadQueuePeon) 
stale.getPeon()).getProfileFor(segment));
+    Assertions.assertEquals(
+        1,
+        empty1.getLoadingSegments().size() + 
empty2.getLoadingSegments().size(),
+        "exactly one of the empty servers takes the remaining replica"
+    );
+  }
+
+  @Test
+  public void testFreshLoadCoversTheDeficitWhenTheInPlaceReloadCannotBeQueued()
+  {
+    // The in-place reload is preferred, but if queueing it on the stale 
server fails the deficit is still real and a
+    // fresh candidate has to cover it. Without the fall-through the tier 
would sit under-replicated for the run while
+    // an idle server was available.
+    final DataSegment segment = createSegment();
+    final DruidServer staleServer = createDruidServer(TIER1);
+    staleServer.addDataSegment(segment, staleProfileForUsers());
+    final ServerHolder stale =
+        new ServerHolder(staleServer.toImmutableDruidServer(), new 
RefusingLoadQueuePeon());
+    final ServerHolder empty = createServer(TIER1);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, stale, 
empty).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_ASSIGNED, TIER1, 
segment.getDataSource()));
+    Assertions.assertTrue(
+        empty.getLoadingSegments().contains(segment),
+        "the fresh candidate covers the deficit the refused in-place reload 
left"
+    );
+    Assertions.assertEquals(profileForRevenue(), ((TestLoadQueuePeon) 
empty.getPeon()).getProfileFor(segment));
+    Assertions.assertTrue(
+        stale.getPeon().getSegmentsToDrop().isEmpty(),
+        "the stale replica keeps serving until the replacement lands"
+    );
+  }
+
+  @Test
+  public void testLoadQueueFullStaleServerIsReplacedByFreshLoad()
+  {
+    // A stale server already at its per-run load-queue budget is not an 
in-place candidate, so the empty server takes
+    // the replacement instead of the tier stalling on a server that cannot 
accept the request.
+    final DataSegment segment = createSegment();
+    final DataSegment other = createSegment(Intervals.of("2020/2021"));
+    final ServerHolder stale = createServerWithLoadedAndQueueLimit(TIER1, 1, 
staleProfileForUsers(), segment);
+    // Consume the server's single load-queue slot so canReloadInPlace rejects 
it.
+    stale.getPeon().loadSegment(other, SegmentAction.LOAD, null);
+    Assertions.assertTrue(stale.startOperation(SegmentAction.LOAD, other));
+    Assertions.assertTrue(stale.isLoadQueueFull());
+
+    final ServerHolder empty = createServer(TIER1);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, stale, 
empty).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_ASSIGNED, TIER1, 
segment.getDataSource()));
+    Assertions.assertTrue(empty.getLoadingSegments().contains(segment));
+    Assertions.assertNull(((TestLoadQueuePeon) 
stale.getPeon()).getProfileFor(segment));
+  }
+
+  @Test
+  public void testDecommissioningStaleServerIsReplacedByFreshLoad()
+  {
+    // A decommissioning stale replica is on its way out, so reloading it in 
place would be wasted work. The fresh
+    // load on the empty server is what satisfies the rule.
+    final DataSegment segment = createSegment();
+    final ServerHolder decommStale = 
createDecommissioningServerWithLoaded(TIER1, segment, staleProfileForUsers());
+    final ServerHolder empty = createServer(TIER1);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, 
decommStale, empty).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_ASSIGNED, TIER1, 
segment.getDataSource()));
+    Assertions.assertTrue(empty.getLoadingSegments().contains(segment));
+    Assertions.assertNull(((TestLoadQueuePeon) 
decommStale.getPeon()).getProfileFor(segment));
+  }
+
+  @Test
+  public void testCancelledStaleInFlightOnLoadedServerIsReloadedInPlace()
+  {
+    // s1 both serves the segment under a stale fingerprint and has another 
stale load in flight on top of it, so it
+    // classifies as stale-in-flight, not stale-loaded, and is missing from 
the in-place bucket at snapshot time.
+    // Cancelling its load leaves it serving the segment, so it must be picked 
up as an in-place destination: the
+    // fresh-load path filters on canLoadSegment, which rejects a server that 
already has the segment.
+    final DataSegment segment = createSegment();
+    final PartialLoadProfile usersProfile = PartialLoadProfile.forRequest(
+        Map.of("type", "partialProjection", "projections", List.of("users"), 
"fingerprint", FP_USERS),
+        FP_USERS
+    );
+    final DruidServer druidServer = createDruidServer(TIER1);
+    druidServer.addDataSegment(segment, usersProfile);
+    final TestLoadQueuePeon peon = new TestLoadQueuePeon();
+    peon.addInFlightHolder(
+        new SegmentHolder(segment, SegmentAction.LOAD, usersProfile, 
Duration.standardSeconds(10), null)
+    );
+    final ServerHolder s1 = new 
ServerHolder(druidServer.toImmutableDruidServer(), peon);
+    final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, 
s1).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())
+    );
+    Assertions.assertEquals(
+        1L,
+        stats.getSegmentStat(Stats.Segments.PARTIAL_ASSIGNED, TIER1, 
segment.getDataSource()),
+        "the cancelled slot is refilled in place rather than left empty"
+    );
+    Assertions.assertEquals(profileForRevenue(), peon.getProfileFor(segment));
+    Assertions.assertFalse(stats.hasStat(Stats.Segments.ASSIGN_SKIPPED));
+  }
+
   @Test
   public void testStaleDroppedAfterMatchingLoadedSatisfiesRequirement()
   {
@@ -541,12 +714,11 @@ public class StrategicSegmentAssignerPartialTest
   }
 
   @Test
-  public void testStuckStateAdditiveReloadOnStaleServers()
+  public void testInPlaceReloadOnEveryStaleServer()
   {
-    // Both servers hold the segment with mismatched fingerprints (and there 
are no spare servers). Reconciler
-    // queues additive-reload requests on both stale servers; the historical's 
additive-load semantics fill in the
-    // missing parts in place. matching count is still 0 in the same run, so 
stale isn't dropped; but next run, after
-    // the loads land, the servers reclassify as matching.
+    // Both servers hold the segment with mismatched fingerprints. Reconciler 
queues an in-place reload on each; the
+    // historical swaps the rule on the cache entry it already has. matching 
count is still 0 in the same run, so
+    // stale isn't dropped; but next run, after the loads land, the servers 
reclassify as matching.
     final DataSegment segment = createSegment();
     final PartialLoadProfile usersProfile = PartialLoadProfile.forLoaded(
         Map.of("type", "partialProjection", "projections", List.of("users"), 
"fingerprint", FP_USERS),
@@ -1062,6 +1234,25 @@ public class StrategicSegmentAssignerPartialTest
         .build();
   }
 
+  /**
+   * Peon that refuses every load, the way the real peon surfaces an error 
while queueing. {@link
+   * SegmentLoadQueueManager#loadSegment} catches it, rolls the operation back 
off the {@link ServerHolder} and
+   * reports the destination as unusable.
+   */
+  private static class RefusingLoadQueuePeon extends TestLoadQueuePeon
+  {
+    @Override
+    public void loadSegment(
+        DataSegment segment,
+        SegmentAction action,
+        @Nullable PartialLoadProfile profile,
+        @Nullable LoadPeonCallback callback
+    )
+    {
+      throw new IllegalStateException("Cannot queue segment[" + 
segment.getId() + "]");
+    }
+  }
+
   /**
    * Peon that records the segments marked to drop, so that a move source 
shows up as MOVE_FROM in the
    * {@link ServerHolder}'s queue the way the real peon reports it.
@@ -1089,6 +1280,20 @@ public class StrategicSegmentAssignerPartialTest
     }
   }
 
+  /**
+   * A loaded profile under a fingerprint the tests' rule never asks for, i.e. 
a stale <em>partial</em> replica. A
+   * stale replica announcing no profile at all is an in-place candidate too, 
this just exercises the partial-to-
+   * partial swap specifically.
+   */
+  private static PartialLoadProfile staleProfileForUsers()
+  {
+    return PartialLoadProfile.forLoaded(
+        Map.of("type", "partialProjection", "projections", List.of("users"), 
"fingerprint", FP_USERS),
+        FP_USERS,
+        512L
+    );
+  }
+
   private static PartialLoadProfile profileForRevenue()
   {
     return PartialLoadProfile.forRequest(
diff --git 
a/server/src/test/java/org/apache/druid/test/utils/TestSegmentCacheManager.java 
b/server/src/test/java/org/apache/druid/test/utils/TestSegmentCacheManager.java
index f4cbb9dcd6d..34a744f3a8b 100644
--- 
a/server/src/test/java/org/apache/druid/test/utils/TestSegmentCacheManager.java
+++ 
b/server/src/test/java/org/apache/druid/test/utils/TestSegmentCacheManager.java
@@ -31,6 +31,7 @@ import org.apache.druid.segment.loading.AcquireMode;
 import org.apache.druid.segment.loading.AcquireSegmentAction;
 import org.apache.druid.segment.loading.AcquireSegmentResult;
 import org.apache.druid.segment.loading.NoopSegmentCacheManager;
+import org.apache.druid.segment.loading.SegmentLoadingException;
 import org.apache.druid.segment.loading.TombstoneSegmentizerFactory;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
@@ -60,6 +61,12 @@ public class TestSegmentCacheManager extends 
NoopSegmentCacheManager
   private final Set<SegmentId> observedSegmentsRemovedFromCache;
   private final AtomicInteger observedShutdownBootstrapCount;
 
+  /**
+   * Loads still allowed to succeed before {@link #load} starts failing, see 
{@link #failLoadsAfter}. Unlimited
+   * unless a test says otherwise.
+   */
+  private final AtomicInteger remainingSuccessfulLoads = new 
AtomicInteger(Integer.MAX_VALUE);
+
   public TestSegmentCacheManager()
   {
     this(ImmutableSet.of());
@@ -110,9 +117,22 @@ public class TestSegmentCacheManager extends 
NoopSegmentCacheManager
     return segment;
   }
 
+  /**
+   * Makes {@link #load} succeed {@code numSuccessfulLoads} more times and 
fail every load after that. Lets a test
+   * establish a serving replica and then fail a reload of it, which is what 
distinguishes failure cleanup that is
+   * safe from cleanup that would tear down a live replica.
+   */
+  public void failLoadsAfter(int numSuccessfulLoads)
+  {
+    remainingSuccessfulLoads.set(numSuccessfulLoads);
+  }
+
   @Override
-  public DataSegment load(final DataSegment segment)
+  public DataSegment load(final DataSegment segment) throws 
SegmentLoadingException
   {
+    if (remainingSuccessfulLoads.getAndUpdate(remaining -> remaining > 0 ? 
remaining - 1 : remaining) <= 0) {
+      throw new SegmentLoadingException("Test-induced load failure for 
segment[%s]", segment.getId());
+    }
     observedSegments.add(segment);
     getSegmentInternal(segment);
     return segment;


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

Reply via email to