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

gianm 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 ce9886ae1c8 fix: Time-ordered scan correctness and limit performance. 
(#20655)
ce9886ae1c8 is described below

commit ce9886ae1c8dc5fe97c03405cbd5c6532a2bec0c
Author: Gian Merlino <[email protected]>
AuthorDate: Mon Oct 26 21:53:21 2026 -0700

    fix: Time-ordered scan correctness and limit performance. (#20655)
    
    Three changes related to how Scan queries schedule segments
    for processing:
    
    1) ScanQueryRunnerFactory#stableLimitingSort (the priority queue
       strategy that runs when ordering by time with a low limit) now
       skips segments that cannot possibly contribute to the results.
       There was previously some logic partially doing this, but it
       scanned time chunks beyond what was really necessary.
    
    2) ScanQueryRunnerFactory#stableLimitingSort now truncates the
       interval of segments being scanned, such that we avoid gathering
       rows from within a segment when those rows cannot possibly
       contribute to the result.
    
    3) ServerManager now builds one segment runner per descriptor in
       descriptor order when calling QueryRunnerFactory#mergeRunners.
       This enables ScanQueryRunnerFactory to correctly match up segments
       and intervals. Previously this could cause out-of-order results
       with VSF on-demand loading.
---
 .../druid/benchmark/query/ScanBenchmark.java       |  85 +++--
 .../query/ServerManagerForQueryErrorTest.java      | 259 +++++++------
 .../druid/collections/StableLimitingSorter.java    |  12 +
 .../main/java/org/apache/druid/query/Queries.java  |  44 ++-
 .../druid/query/scan/ScanQueryRunnerFactory.java   | 189 +++++-----
 .../query/spec/SpecificSegmentQueryRunner.java     |   9 +-
 .../collections/StableLimitingSorterTest.java      |  28 ++
 .../java/org/apache/druid/query/QueriesTest.java   |  68 ++++
 .../query/scan/ScanQueryRunnerFactoryTest.java     | 403 ++++++++++++++++++++-
 .../query/spec/SpecificSegmentQueryRunnerTest.java |  52 +++
 .../apache/druid/client/CachingQueryRunner.java    |  11 +-
 .../org/apache/druid/server/ServerManager.java     |  68 ++--
 .../druid/client/CachingQueryRunnerTest.java       |  50 ++-
 .../ServerManagerScanOrderingTest.java             | 357 ++++++++++++++++++
 14 files changed, 1352 insertions(+), 283 deletions(-)

diff --git 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/ScanBenchmark.java 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/ScanBenchmark.java
index 2662071188d..ef6c17fb29c 100644
--- 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/ScanBenchmark.java
+++ 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/ScanBenchmark.java
@@ -58,6 +58,8 @@ import org.apache.druid.query.scan.ScanResultValue;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
 import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.query.spec.SpecificSegmentQueryRunner;
+import org.apache.druid.query.spec.SpecificSegmentSpec;
 import org.apache.druid.segment.IncrementalIndexSegment;
 import org.apache.druid.segment.IndexIO;
 import org.apache.druid.segment.IndexMergerV9;
@@ -75,6 +77,7 @@ import 
org.apache.druid.segment.incremental.OnheapIncrementalIndex;
 import org.apache.druid.segment.serde.ComplexMetrics;
 import 
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
 import org.apache.druid.timeline.SegmentId;
+import org.joda.time.Interval;
 import org.openjdk.jmh.annotations.Benchmark;
 import org.openjdk.jmh.annotations.BenchmarkMode;
 import org.openjdk.jmh.annotations.Fork;
@@ -112,7 +115,7 @@ public class ScanBenchmark
   @Param({"basic.A", "basic.B", "basic.C", "basic.D"})
   private String schemaAndQuery;
 
-  @Param({"1000", "99999"})
+  @Param({"10", "1000", "99999"})
   private int limit;
 
   @Param({"NONE", "DESCENDING", "ASCENDING"})
@@ -301,8 +304,17 @@ public class ScanBenchmark
   @State(Scope.Benchmark)
   public static class QueryableIndexState
   {
+    /**
+     * Number of time chunks that evenly divide the data interval of the 
schema.
+     */
+    @Param({"1", "8"})
+    private int numTimeChunks;
+
+    /**
+     * Number of segments per time chunk. Each one spans its whole time chunk.
+     */
     @Param({"2", "4"})
-    private int numSegments;
+    private int numPartitionsPerTimeChunk;
 
     @Param({"2"})
     private int numProcessingThreads;
@@ -311,6 +323,11 @@ public class ScanBenchmark
     private File qIndexesDir;
     private List<QueryableIndex> qIndexes;
 
+    /**
+     * IDs of {@link #qIndexes}, in time order.
+     */
+    private List<SegmentId> segmentIds;
+
     @Setup
     public void setup(ScanBenchmark global) throws IOException
     {
@@ -320,22 +337,39 @@ public class ScanBenchmark
 
       qIndexesDir = FileUtils.createTempDir();
       qIndexes = new ArrayList<>();
+      segmentIds = new ArrayList<>();
 
-      for (int i = 0; i < numSegments; i++) {
-        log.info("Generating rows for segment " + i);
-
-        IncrementalIndex incIndex = global.makeIncIndex();
-        global.generator.reset(RNG_SEED + i).addToIndex(incIndex, 
global.rowsPerSegment);
-
-        File indexFile = INDEX_MERGER_V9.persist(
-            incIndex,
-            new File(qIndexesDir, String.valueOf(i)),
-            IndexSpec.getDefault(),
-            null
+      final Interval dataInterval = global.schemaInfo.getDataInterval();
+      for (int chunk = 0; chunk < numTimeChunks; chunk++) {
+        final Interval chunkInterval = Intervals.utc(
+            dataInterval.getStartMillis() + dataInterval.toDurationMillis() * 
chunk / numTimeChunks,
+            dataInterval.getStartMillis() + dataInterval.toDurationMillis() * 
(chunk + 1) / numTimeChunks
         );
-        incIndex.close();
 
-        qIndexes.add(INDEX_IO.loadIndex(indexFile));
+        for (int partition = 0; partition < numPartitionsPerTimeChunk; 
partition++) {
+          final int i = qIndexes.size();
+          log.info("Generating rows for segment " + i);
+
+          final DataGenerator generator = new DataGenerator(
+              global.schemaInfo.getColumnSchemas(),
+              RNG_SEED + i,
+              chunkInterval,
+              global.rowsPerSegment
+          );
+          IncrementalIndex incIndex = global.makeIncIndex();
+          generator.addToIndex(incIndex, global.rowsPerSegment);
+
+          File indexFile = INDEX_MERGER_V9.persist(
+              incIndex,
+              new File(qIndexesDir, String.valueOf(i)),
+              IndexSpec.getDefault(),
+              null
+          );
+          incIndex.close();
+
+          qIndexes.add(INDEX_IO.loadIndex(indexFile));
+          segmentIds.add(SegmentId.of("qIndex", chunkInterval, 
"dummy_version", partition));
+        }
       }
     }
 
@@ -441,21 +475,22 @@ public class ScanBenchmark
     List<SegmentDescriptor> segmentDescriptors = new ArrayList<>();
     List<QueryRunner<Row>> runners = new ArrayList<>();
     QueryToolChest toolChest = factory.getToolchest();
-    for (int i = 0; i < state.numSegments; i++) {
-      String segmentName = "qIndex";
+    for (int i = 0; i < state.qIndexes.size(); i++) {
+      final SegmentId segmentId = state.segmentIds.get(i);
       final QueryRunner<Result<ScanResultValue>> runner = 
QueryBenchmarkUtil.makeQueryRunner(
           factory,
-          SegmentId.dummy(segmentName),
-          new QueryableIndexSegment(state.qIndexes.get(i), 
SegmentId.dummy(segmentName, i))
+          segmentId,
+          new QueryableIndexSegment(state.qIndexes.get(i), segmentId)
       );
-      segmentDescriptors.add(
-          new SegmentDescriptor(
-              Intervals.ETERNITY,
-              "dummy_version",
-              i
+      segmentDescriptors.add(segmentId.toDescriptor());
+
+      // Restrict each segment to its descriptor, as data servers do.
+      runners.add(
+          new SpecificSegmentQueryRunner<>(
+              toolChest.preMergeQueryDecoration(runner),
+              new SpecificSegmentSpec(segmentId.toDescriptor())
           )
       );
-      runners.add(toolChest.preMergeQueryDecoration(runner));
     }
 
     QueryRunner theRunner = toolChest.postMergeQueryDecoration(
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/ServerManagerForQueryErrorTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/ServerManagerForQueryErrorTest.java
index 847ed8e8933..0d8f4f8105a 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/ServerManagerForQueryErrorTest.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/ServerManagerForQueryErrorTest.java
@@ -26,10 +26,8 @@ import org.apache.commons.lang3.mutable.MutableBoolean;
 import org.apache.druid.client.cache.Cache;
 import org.apache.druid.client.cache.CacheConfig;
 import org.apache.druid.client.cache.CachePopulator;
-import org.apache.druid.error.DruidException;
 import org.apache.druid.guice.annotations.Smile;
 import org.apache.druid.java.util.common.guava.Accumulator;
-import org.apache.druid.java.util.common.guava.FunctionalIterable;
 import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.guava.Sequences;
 import org.apache.druid.java.util.common.guava.Yielder;
@@ -246,145 +244,138 @@ public class ServerManagerForQueryErrorTest extends 
ServerManager
   }
 
   @Override
-  protected <T> FunctionalIterable<QueryRunner<T>> getQueryRunnersForSegments(
-      Query<T> query,
-      QueryRunnerFactory<T, Query<T>> factory,
-      QueryToolChest<T, Query<T>> toolChest,
-      List<SegmentReference> segmentReferences,
-      AtomicLong cpuTimeAccumulator,
-      Optional<byte[]> cacheKeyPrefix
+  protected <T> QueryRunner<T> buildQueryRunnerForSegment(
+      final SegmentDescriptor segmentDescriptor,
+      final Segment segment,
+      final QueryRunnerFactory<T, Query<T>> factory,
+      final QueryToolChest<T, Query<T>> toolChest,
+      final AtomicLong cpuTimeAccumulator,
+      final Optional<byte[]> cacheKeyPrefix
   )
   {
-    return FunctionalIterable
-        .create(segmentReferences)
-        .transform(
-            ref ->
-                ref.getSegmentReference()
-                   .map(segment -> {
-                     final QueryContext queryContext = query.context();
-                     if 
(queryContext.getBoolean(QUERY_TIMEOUT_TEST_CONTEXT_KEY, false)) {
-                       return (QueryRunner<T>) (queryPlus, responseContext) -> 
new Sequence<>()
-                       {
-                         @Override
-                         public <OutType> OutType accumulate(
-                             OutType initValue,
-                             Accumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new QueryTimeoutException("query timeout 
test");
-                         }
+    final QueryRunner<T> baseRunner = super.buildQueryRunnerForSegment(
+        segmentDescriptor,
+        segment,
+        factory,
+        toolChest,
+        cpuTimeAccumulator,
+        cacheKeyPrefix
+    );
+
+    return (queryPlus, responseContext) -> {
+      final QueryContext queryContext = queryPlus.getQuery().context();
+      if (queryContext.getBoolean(QUERY_TIMEOUT_TEST_CONTEXT_KEY, false)) {
+        return new Sequence<>()
+        {
+          @Override
+          public <OutType> OutType accumulate(
+              OutType initValue,
+              Accumulator<OutType, T> accumulator
+          )
+          {
+            throw new QueryTimeoutException("query timeout test");
+          }
 
-                         @Override
-                         public <OutType> Yielder<OutType> toYielder(
-                             OutType initValue,
-                             YieldingAccumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new QueryTimeoutException("query timeout 
test");
-                         }
-                       };
-                     } else if 
(queryContext.getBoolean(QUERY_CAPACITY_EXCEEDED_TEST_CONTEXT_KEY, false)) {
-                       return (QueryRunner<T>) (queryPlus, responseContext) -> 
new Sequence<>()
-                       {
-                         @Override
-                         public <OutType> OutType accumulate(
-                             OutType initValue,
-                             Accumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw 
QueryCapacityExceededException.withErrorMessageAndResolvedHost(
-                               "query capacity exceeded test"
-                           );
-                         }
+          @Override
+          public <OutType> Yielder<OutType> toYielder(
+              OutType initValue,
+              YieldingAccumulator<OutType, T> accumulator
+          )
+          {
+            throw new QueryTimeoutException("query timeout test");
+          }
+        };
+      } else if 
(queryContext.getBoolean(QUERY_CAPACITY_EXCEEDED_TEST_CONTEXT_KEY, false)) {
+        return new Sequence<>()
+        {
+          @Override
+          public <OutType> OutType accumulate(
+              OutType initValue,
+              Accumulator<OutType, T> accumulator
+          )
+          {
+            throw 
QueryCapacityExceededException.withErrorMessageAndResolvedHost(
+                "query capacity exceeded test"
+            );
+          }
 
-                         @Override
-                         public <OutType> Yielder<OutType> toYielder(
-                             OutType initValue,
-                             YieldingAccumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw 
QueryCapacityExceededException.withErrorMessageAndResolvedHost(
-                               "query capacity exceeded test"
-                           );
-                         }
-                       };
-                     } else if 
(queryContext.getBoolean(QUERY_UNSUPPORTED_TEST_CONTEXT_KEY, false)) {
-                       return (QueryRunner<T>) (queryPlus, responseContext) -> 
new Sequence<>()
-                       {
-                         @Override
-                         public <OutType> OutType accumulate(
-                             OutType initValue,
-                             Accumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new QueryUnsupportedException("query 
unsupported test");
-                         }
+          @Override
+          public <OutType> Yielder<OutType> toYielder(
+              OutType initValue,
+              YieldingAccumulator<OutType, T> accumulator
+          )
+          {
+            throw 
QueryCapacityExceededException.withErrorMessageAndResolvedHost(
+                "query capacity exceeded test"
+            );
+          }
+        };
+      } else if (queryContext.getBoolean(QUERY_UNSUPPORTED_TEST_CONTEXT_KEY, 
false)) {
+        return new Sequence<>()
+        {
+          @Override
+          public <OutType> OutType accumulate(
+              OutType initValue,
+              Accumulator<OutType, T> accumulator
+          )
+          {
+            throw new QueryUnsupportedException("query unsupported test");
+          }
 
-                         @Override
-                         public <OutType> Yielder<OutType> toYielder(
-                             OutType initValue,
-                             YieldingAccumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new QueryUnsupportedException("query 
unsupported test");
-                         }
-                       };
-                     } else if 
(queryContext.getBoolean(RESOURCE_LIMIT_EXCEEDED_TEST_CONTEXT_KEY, false)) {
-                       return (QueryRunner<T>) (queryPlus, responseContext) -> 
new Sequence<>()
-                       {
-                         @Override
-                         public <OutType> OutType accumulate(
-                             OutType initValue,
-                             Accumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new ResourceLimitExceededException("resource 
limit exceeded test");
-                         }
+          @Override
+          public <OutType> Yielder<OutType> toYielder(
+              OutType initValue,
+              YieldingAccumulator<OutType, T> accumulator
+          )
+          {
+            throw new QueryUnsupportedException("query unsupported test");
+          }
+        };
+      } else if 
(queryContext.getBoolean(RESOURCE_LIMIT_EXCEEDED_TEST_CONTEXT_KEY, false)) {
+        return new Sequence<>()
+        {
+          @Override
+          public <OutType> OutType accumulate(
+              OutType initValue,
+              Accumulator<OutType, T> accumulator
+          )
+          {
+            throw new ResourceLimitExceededException("resource limit exceeded 
test");
+          }
 
-                         @Override
-                         public <OutType> Yielder<OutType> toYielder(
-                             OutType initValue,
-                             YieldingAccumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new ResourceLimitExceededException("resource 
limit exceeded test");
-                         }
-                       };
-                     } else if 
(queryContext.getBoolean(QUERY_FAILURE_TEST_CONTEXT_KEY, false)) {
-                       return (QueryRunner<T>) (queryPlus, responseContext) -> 
new Sequence<>()
-                       {
-                         @Override
-                         public <OutType> OutType accumulate(
-                             OutType initValue,
-                             Accumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new RuntimeException("query failure test");
-                         }
+          @Override
+          public <OutType> Yielder<OutType> toYielder(
+              OutType initValue,
+              YieldingAccumulator<OutType, T> accumulator
+          )
+          {
+            throw new ResourceLimitExceededException("resource limit exceeded 
test");
+          }
+        };
+      } else if (queryContext.getBoolean(QUERY_FAILURE_TEST_CONTEXT_KEY, 
false)) {
+        return new Sequence<>()
+        {
+          @Override
+          public <OutType> OutType accumulate(
+              OutType initValue,
+              Accumulator<OutType, T> accumulator
+          )
+          {
+            throw new RuntimeException("query failure test");
+          }
 
-                         @Override
-                         public <OutType> Yielder<OutType> toYielder(
-                             OutType initValue,
-                             YieldingAccumulator<OutType, T> accumulator
-                         )
-                         {
-                           throw new RuntimeException("query failure test");
-                         }
-                       };
-                     }
+          @Override
+          public <OutType> Yielder<OutType> toYielder(
+              OutType initValue,
+              YieldingAccumulator<OutType, T> accumulator
+          )
+          {
+            throw new RuntimeException("query failure test");
+          }
+        };
+      }
 
-                     return buildQueryRunnerForSegment(
-                         ref.getSegmentDescriptor(),
-                         segment,
-                         factory,
-                         toolChest,
-                         cpuTimeAccumulator,
-                         cacheKeyPrefix
-                     );
-                   })
-                   .orElseThrow(
-                       () -> DruidException.defensive("Unexpected missing 
segment[%s]", ref.getSegmentDescriptor())
-                   )
-        );
+      return baseRunner.run(queryPlus, responseContext);
+    };
   }
 }
diff --git 
a/processing/src/main/java/org/apache/druid/collections/StableLimitingSorter.java
 
b/processing/src/main/java/org/apache/druid/collections/StableLimitingSorter.java
index d0c53781ca7..6ce8313a1ac 100644
--- 
a/processing/src/main/java/org/apache/druid/collections/StableLimitingSorter.java
+++ 
b/processing/src/main/java/org/apache/druid/collections/StableLimitingSorter.java
@@ -24,6 +24,7 @@ import com.google.common.collect.MinMaxPriorityQueue;
 import com.google.common.collect.Ordering;
 import com.google.common.primitives.Ints;
 
+import javax.annotation.Nullable;
 import java.util.Comparator;
 import java.util.Iterator;
 import java.util.Objects;
@@ -75,6 +76,17 @@ public class StableLimitingSorter<T>
     return Ints.checkedCast(Math.min(size, limit));
   }
 
+  /**
+   * Returns the greatest element currently retained, or null if there are 
none. Once the sorter is full, an element
+   * offered to {@link #add} is retained only if it sorts strictly before this 
one.
+   */
+  @Nullable
+  public T peekLast()
+  {
+    final NumberedElement<T> last = queue.peekLast();
+    return last == null ? null : last.getElement();
+  }
+
   /**
    * Drain elements in sorted order (least first).
    */
diff --git a/processing/src/main/java/org/apache/druid/query/Queries.java 
b/processing/src/main/java/org/apache/druid/query/Queries.java
index 842a047edbe..51d75cb8e79 100644
--- a/processing/src/main/java/org/apache/druid/query/Queries.java
+++ b/processing/src/main/java/org/apache/druid/query/Queries.java
@@ -33,6 +33,7 @@ import 
org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.segment.VirtualColumn;
 import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnHolder;
+import org.joda.time.Interval;
 
 import javax.annotation.Nullable;
 
@@ -178,11 +179,52 @@ public class Queries
     return retVal;
   }
 
+  /**
+   * Returns a {@link SegmentDescriptor} with its interval narrowed to the 
intervals of the lowest-level
+   * subquery of the provided {@link Query} (the same one that {@link 
#withSpecificSegments(Query, List)} modifies),
+   * or null if they do not overlap.
+   *
+   * <p>Data servers build each descriptor from the query's original 
intervals, so this normally returns
+   * {@code descriptor} unchanged. It differs only when a query engine 
rewrites its query to narrow the
+   * interval further.
+   */
+  @Nullable
+  public static SegmentDescriptor narrowToQuery(final SegmentDescriptor 
descriptor, final Query<?> query)
+  {
+    if (query.getDataSource() instanceof QueryDataSource) {
+      return narrowToQuery(descriptor, ((QueryDataSource) 
query.getDataSource()).getQuery());
+    }
+
+    // Query intervals are condensed, so each one overlaps a different, 
non-abutting part of the descriptor.
+    Interval narrowedInterval = null;
+    for (final Interval queryInterval : query.getIntervals()) {
+      final Interval overlap = descriptor.getInterval().overlap(queryInterval);
+      if (overlap != null && overlap.toDurationMillis() > 0) {
+        if (narrowedInterval != null) {
+          throw DruidException.defensive(
+              "Query intervals[%s] split segment interval[%s]",
+              query.getIntervals(),
+              descriptor.getInterval()
+          );
+        }
+        narrowedInterval = overlap;
+      }
+    }
+
+    if (narrowedInterval == null) {
+      return null;
+    } else if (narrowedInterval.equals(descriptor.getInterval())) {
+      return descriptor;
+    } else {
+      return new SegmentDescriptor(narrowedInterval, descriptor.getVersion(), 
descriptor.getPartitionNumber());
+    }
+  }
+
   /**
    * Rewrite "query" to refer to some specific base datasource, instead of the 
one it currently refers to.
    *
    * Unlike the seemingly-similar {@link Query#withDataSource}, this will walk 
down the datasource tree and replace
-   * only the base datasource (in the sense defined in {@link 
DataSourceAnalysis}).
+   * only the base datasource (in the sense defined in {@link 
ExecutionVertex#getBaseDataSource()}).
    */
   public static Query withBaseDataSource(final Query query, final DataSource 
newBaseDataSource)
   {
diff --git 
a/processing/src/main/java/org/apache/druid/query/scan/ScanQueryRunnerFactory.java
 
b/processing/src/main/java/org/apache/druid/query/scan/ScanQueryRunnerFactory.java
index f09b42c2795..466f081e39a 100644
--- 
a/processing/src/main/java/org/apache/druid/query/scan/ScanQueryRunnerFactory.java
+++ 
b/processing/src/main/java/org/apache/druid/query/scan/ScanQueryRunnerFactory.java
@@ -24,14 +24,14 @@ import com.google.common.collect.Iterators;
 import com.google.common.collect.Lists;
 import com.google.inject.Inject;
 import org.apache.druid.collections.StableLimitingSorter;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.JodaUtils;
 import org.apache.druid.java.util.common.Pair;
 import org.apache.druid.java.util.common.UOE;
 import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.guava.Sequences;
-import org.apache.druid.java.util.common.guava.Yielder;
-import org.apache.druid.java.util.common.guava.Yielders;
 import org.apache.druid.query.Order;
 import org.apache.druid.query.Query;
 import org.apache.druid.query.QueryPlus;
@@ -43,13 +43,13 @@ import 
org.apache.druid.query.ResourceLimitExceededException;
 import org.apache.druid.query.SegmentDescriptor;
 import org.apache.druid.query.SinkQueryRunners;
 import org.apache.druid.query.context.ResponseContext;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
 import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.query.spec.QuerySegmentSpec;
 import org.apache.druid.query.spec.SpecificSegmentSpec;
 import org.apache.druid.segment.Segment;
 import org.joda.time.Interval;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -111,46 +111,16 @@ public class ScanQueryRunnerFactory implements 
QueryRunnerFactory<ScanResultValu
           return returnedRows;
         }
       } else {
-        List<Interval> intervalsOrdered = 
getIntervalsFromSpecificQuerySpec(query.getQuerySegmentSpec());
-        List<QueryRunner<ScanResultValue>> queryRunnersOrdered = 
Lists.newArrayList(queryRunners);
-
-        if (Order.DESCENDING.equals(query.getTimeOrder())) {
-          intervalsOrdered = Lists.reverse(intervalsOrdered);
-          queryRunnersOrdered = Lists.reverse(queryRunnersOrdered);
-        }
+        final List<Pair<Interval, QueryRunner<ScanResultValue>>> 
intervalsAndRunnersOrdered =
+            getIntervalsAndRunnersOrdered(query, queryRunners);
         int maxRowsQueuedForOrdering = (query.getMaxRowsQueuedForOrdering() == 
null
                                         ? 
scanQueryConfig.getMaxRowsQueuedForOrdering()
                                         : query.getMaxRowsQueuedForOrdering());
         if (query.getScanRowsLimit() <= maxRowsQueuedForOrdering) {
           // Use priority queue strategy
-          try {
-            return stableLimitingSort(
-                Sequences.concat(Sequences.map(
-                    Sequences.simple(queryRunnersOrdered),
-                    input -> input.run(queryPlus, responseContext)
-                )),
-                query,
-                intervalsOrdered
-            );
-          }
-          catch (IOException e) {
-            throw new RuntimeException(e);
-          }
+          return stableLimitingSort(intervalsAndRunnersOrdered, queryPlus, 
responseContext);
         } else {
           // Use n-way merge strategy
-          List<Pair<Interval, QueryRunner<ScanResultValue>>> 
intervalsAndRunnersOrdered = new ArrayList<>();
-          if (intervalsOrdered.size() == queryRunnersOrdered.size()) {
-            for (int i = 0; i < queryRunnersOrdered.size(); i++) {
-              intervalsAndRunnersOrdered.add(new 
Pair<>(intervalsOrdered.get(i), queryRunnersOrdered.get(i)));
-            }
-          } else if (queryRunners instanceof SinkQueryRunners) {
-            ((SinkQueryRunners<ScanResultValue>) 
queryRunners).runnerIntervalMappingIterator()
-                                                              
.forEachRemaining(intervalsAndRunnersOrdered::add);
-          } else {
-            throw new ISE("Number of segment descriptors does not equal number 
of "
-                          + "query runners...something went wrong!");
-          }
-
           // Group the list of pairs by interval.  The LinkedHashMap will have 
an interval paired with a list of all the
           // query runners for that segment
           LinkedHashMap<Interval, List<Pair<Interval, 
QueryRunner<ScanResultValue>>>> partitionsGroupedByInterval =
@@ -180,12 +150,12 @@ public class ScanQueryRunnerFactory implements 
QueryRunnerFactory<ScanResultValu
             // there should be no interval overlap.  We create a list of lists 
so we can create a sequence of sequences.
             // There's no easy way to convert a LinkedHashMap to a sequence 
because it's non-iterable.
             List<List<QueryRunner<ScanResultValue>>> groupedRunners =
-                partitionsGroupedByInterval.entrySet()
+                partitionsGroupedByInterval.values()
                                            .stream()
-                                           .map(entry -> entry.getValue()
-                                                              .stream()
-                                                              
.map(segQueryRunnerPair -> segQueryRunnerPair.rhs)
-                                                              
.collect(Collectors.toList()))
+                                           .map(pairs -> pairs
+                                               .stream()
+                                               .map(segQueryRunnerPair -> 
segQueryRunnerPair.rhs)
+                                               .collect(Collectors.toList()))
                                            .collect(Collectors.toList());
 
             return nWayMergeAndLimit(groupedRunners, queryPlus, 
responseContext);
@@ -206,17 +176,18 @@ public class ScanQueryRunnerFactory implements 
QueryRunnerFactory<ScanResultValu
   }
 
   /**
-   * Returns a sorted and limited copy of the provided {@param inputSequence}. 
Materializes the full sequence
-   * in memory before returning it. The amount of memory use is limited by the 
limit of the {@param scanQuery}.
+   * Returns the first rows, up to the limit of the query, from the provided 
runners in the order given by
+   * {@link ScanQuery#getResultOrdering()}. Runs the runners one at a time, in 
the provided order, and materializes the
+   * result in memory. Skips runners that could not possibly contribute to the 
results.
    */
   @VisibleForTesting
   Sequence<ScanResultValue> stableLimitingSort(
-      Sequence<ScanResultValue> inputSequence,
-      ScanQuery scanQuery,
-      List<Interval> intervalsOrdered
-  ) throws IOException
+      final List<Pair<Interval, QueryRunner<ScanResultValue>>> 
intervalsAndRunnersOrdered,
+      final QueryPlus<ScanResultValue> queryPlus,
+      final ResponseContext responseContext
+  )
   {
-    Comparator<ScanResultValue> comparator = scanQuery.getResultOrdering();
+    final ScanQuery scanQuery = (ScanQuery) queryPlus.getQuery();
 
     if (scanQuery.getScanRowsLimit() > Integer.MAX_VALUE) {
       throw new UOE(
@@ -227,52 +198,92 @@ public class ScanQueryRunnerFactory implements 
QueryRunnerFactory<ScanResultValu
 
     // Converting the limit from long to int could theoretically throw an 
ArithmeticException but this branch
     // only runs if limit < MAX_LIMIT_FOR_IN_MEMORY_TIME_ORDERING (which 
should be < Integer.MAX_VALUE)
-    int limit = Math.toIntExact(scanQuery.getScanRowsLimit());
-
-    final StableLimitingSorter<ScanResultValue> sorter = new 
StableLimitingSorter<>(comparator, limit);
-
-    Yielder<ScanResultValue> yielder = Yielders.each(inputSequence);
-
-    try {
-      boolean doneScanning = yielder.isDone();
-      // We need to scan limit elements and anything else in the last segment
-      int numRowsScanned = 0;
-      Interval finalInterval = null;
-      while (!doneScanning) {
-        ScanResultValue next = yielder.get();
-        List<ScanResultValue> singleEventScanResultValues = 
next.toSingleEventScanResultValues();
-        for (ScanResultValue srv : singleEventScanResultValues) {
-          numRowsScanned++;
-          // Using an intermediate unbatched ScanResultValue is not that great 
memory-wise, but the column list
-          // needs to be preserved for queries using the compactedList result 
format
-          sorter.add(srv);
-
-          // Finish scanning the interval containing the limit row
-          if (numRowsScanned > limit && finalInterval == null) {
-            long timestampOfLimitRow = 
srv.getFirstEventTimestamp(scanQuery.getResultFormat());
-            for (Interval interval : intervalsOrdered) {
-              if (interval.contains(timestampOfLimitRow)) {
-                finalInterval = interval;
-              }
-            }
-            if (finalInterval == null) {
-              throw new ISE("Row came from an unscanned interval");
-            }
-          }
+    final int limit = Math.toIntExact(scanQuery.getScanRowsLimit());
+
+    final StableLimitingSorter<ScanResultValue> sorter =
+        new StableLimitingSorter<>(scanQuery.getResultOrdering(), limit);
+
+    for (final Pair<Interval, QueryRunner<ScanResultValue>> intervalAndRunner 
: intervalsAndRunnersOrdered) {
+      QueryPlus<ScanResultValue> runnerQueryPlus = queryPlus;
+
+      if (sorter.size() == limit) {
+        final Interval includableInterval = 
computeIncludableInterval(scanQuery, sorter.peekLast());
+        if (!intervalAndRunner.lhs.overlaps(includableInterval)) {
+          continue;
         }
-        yielder = yielder.next(null);
-        doneScanning = yielder.isDone() ||
-                       (finalInterval != null &&
-                        
!finalInterval.contains(next.getFirstEventTimestamp(scanQuery.getResultFormat())));
+
+        // Narrow to the part of this runner's interval that could still be 
included, so its cursor can skip the rest.
+        // The overlap is non-null, since we just checked that the intervals 
overlap.
+        final Interval narrowedInterval = 
intervalAndRunner.lhs.overlap(includableInterval);
+        runnerQueryPlus = queryPlus.withQuery(
+            scanQuery.withQuerySegmentSpec(new 
MultipleIntervalSegmentSpec(List.of(narrowedInterval)))
+        );
       }
 
-      final List<ScanResultValue> sortedElements = new 
ArrayList<>(sorter.size());
-      Iterators.addAll(sortedElements, sorter.drain());
-      return Sequences.simple(sortedElements);
+      intervalAndRunner.rhs.run(runnerQueryPlus, responseContext).accumulate(
+          sorter,
+          (theSorter, batch) -> {
+            for (final ScanResultValue srv : 
batch.toSingleEventScanResultValues()) {
+              // Using an intermediate unbatched ScanResultValue is not that 
great memory-wise, but the column list
+              // needs to be preserved for queries using the compactedList 
result format
+              theSorter.add(srv);
+            }
+            return theSorter;
+          }
+      );
+    }
+
+    final List<ScanResultValue> sortedElements = new 
ArrayList<>(sorter.size());
+    Iterators.addAll(sortedElements, sorter.drain());
+    return Sequences.simple(sortedElements);
+  }
+
+  /**
+   * Returns the timestamps that a row must have to be included in the result, 
given that the result is full and its
+   * current last row is {@code lastRow}. Includes the timestamp of {@code 
lastRow}, since rows with equal timestamps
+   * are ordered by segment ID, so a later row with that timestamp may still 
be included.
+   */
+  private static Interval computeIncludableInterval(final ScanQuery query, 
final ScanResultValue lastRow)
+  {
+    final long lastTimestamp = 
lastRow.getFirstEventTimestamp(query.getResultFormat());
+    if (Order.DESCENDING.equals(query.getTimeOrder())) {
+      return Intervals.utc(lastTimestamp, JodaUtils.MAX_INSTANT);
+    } else {
+      return Intervals.utc(JodaUtils.MIN_INSTANT, lastTimestamp + 1);
     }
-    finally {
-      yielder.close();
+  }
+
+  /**
+   * Returns each runner paired with the interval of the segment it reads, in 
the order given by
+   * {@link ScanQuery#getTimeOrder()}.
+   */
+  private List<Pair<Interval, QueryRunner<ScanResultValue>>> 
getIntervalsAndRunnersOrdered(
+      final ScanQuery query,
+      final Iterable<QueryRunner<ScanResultValue>> queryRunners
+  )
+  {
+    final List<Pair<Interval, QueryRunner<ScanResultValue>>> 
intervalsAndRunners = new ArrayList<>();
+
+    if (queryRunners instanceof SinkQueryRunners) {
+      ((SinkQueryRunners<ScanResultValue>) 
queryRunners).runnerIntervalMappingIterator()
+                                                        
.forEachRemaining(intervalsAndRunners::add);
+    } else {
+      final List<Interval> intervals = 
getIntervalsFromSpecificQuerySpec(query.getQuerySegmentSpec());
+      final List<QueryRunner<ScanResultValue>> runners = 
Lists.newArrayList(queryRunners);
+      if (intervals.size() != runners.size()) {
+        throw DruidException.defensive(
+            "Number of segment descriptors[%d] does not equal number of query 
runners[%d]",
+            intervals.size(),
+            runners.size()
+        );
+      }
+      for (int i = 0; i < runners.size(); i++) {
+        intervalsAndRunners.add(Pair.of(intervals.get(i), runners.get(i)));
+      }
     }
+
+    // Descriptors and runners are in ascending time order.
+    return Order.DESCENDING.equals(query.getTimeOrder()) ? 
Lists.reverse(intervalsAndRunners) : intervalsAndRunners;
   }
 
   @VisibleForTesting
diff --git 
a/processing/src/main/java/org/apache/druid/query/spec/SpecificSegmentQueryRunner.java
 
b/processing/src/main/java/org/apache/druid/query/spec/SpecificSegmentQueryRunner.java
index c686316a9c7..24c02ee8906 100644
--- 
a/processing/src/main/java/org/apache/druid/query/spec/SpecificSegmentQueryRunner.java
+++ 
b/processing/src/main/java/org/apache/druid/query/spec/SpecificSegmentQueryRunner.java
@@ -31,6 +31,7 @@ import org.apache.druid.query.Queries;
 import org.apache.druid.query.Query;
 import org.apache.druid.query.QueryPlus;
 import org.apache.druid.query.QueryRunner;
+import org.apache.druid.query.SegmentDescriptor;
 import org.apache.druid.query.context.QueryContextParameters;
 import org.apache.druid.query.context.ResponseContext;
 import org.apache.druid.segment.SegmentMissingException;
@@ -58,10 +59,16 @@ public class SpecificSegmentQueryRunner<T> implements 
QueryRunner<T>
   @Override
   public Sequence<T> run(final QueryPlus<T> input, final ResponseContext 
responseContext)
   {
+    final SegmentDescriptor descriptor = 
Queries.narrowToQuery(specificSpec.getDescriptor(), input.getQuery());
+    if (descriptor == null) {
+      // Nothing to read, since the query does not overlap this segment.
+      return Sequences.empty();
+    }
+
     final QueryPlus<T> queryPlus = input.withQuery(
         Queries.withSpecificSegments(
             input.getQuery(),
-            Collections.singletonList(specificSpec.getDescriptor())
+            Collections.singletonList(descriptor)
         )
     );
 
diff --git 
a/processing/src/test/java/org/apache/druid/collections/StableLimitingSorterTest.java
 
b/processing/src/test/java/org/apache/druid/collections/StableLimitingSorterTest.java
index fe2a92fbfc8..a82f5494a35 100644
--- 
a/processing/src/test/java/org/apache/druid/collections/StableLimitingSorterTest.java
+++ 
b/processing/src/test/java/org/apache/druid/collections/StableLimitingSorterTest.java
@@ -19,9 +19,14 @@
 
 package org.apache.druid.collections;
 
+import com.google.common.collect.Lists;
 import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.Comparator;
+import java.util.List;
+
 public class StableLimitingSorterTest
 {
   @Test
@@ -29,4 +34,27 @@ public class StableLimitingSorterTest
   {
     
EqualsVerifier.forClass(StableLimitingSorter.NumberedElement.class).usingGetClass().verify();
   }
+
+  @Test
+  public void testPeekLast()
+  {
+    // Compare only the first character, so "b1" and "b2" are equal.
+    final StableLimitingSorter<String> sorter =
+        new StableLimitingSorter<>(Comparator.comparing(s -> s.charAt(0)), 2);
+    Assertions.assertNull(sorter.peekLast());
+
+    sorter.add("c");
+    Assertions.assertEquals("c", sorter.peekLast());
+
+    sorter.add("b1");
+    Assertions.assertEquals("c", sorter.peekLast());
+
+    sorter.add("a");
+    Assertions.assertEquals("b1", sorter.peekLast());
+
+    // Equal to the greatest element, but added later, so it is not retained.
+    sorter.add("b2");
+    Assertions.assertEquals("b1", sorter.peekLast());
+    Assertions.assertEquals(List.of("a", "b1"), 
Lists.newArrayList(sorter.drain()));
+  }
 }
diff --git a/processing/src/test/java/org/apache/druid/query/QueriesTest.java 
b/processing/src/test/java/org/apache/druid/query/QueriesTest.java
index ce3a4cd2da1..aa1a3a84d73 100644
--- a/processing/src/test/java/org/apache/druid/query/QueriesTest.java
+++ b/processing/src/test/java/org/apache/druid/query/QueriesTest.java
@@ -35,6 +35,7 @@ import 
org.apache.druid.query.aggregation.post.FieldAccessPostAggregator;
 import org.apache.druid.query.context.QueryContextParameters;
 import org.apache.druid.query.filter.TrueDimFilter;
 import org.apache.druid.query.planning.ExecutionVertexTest;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
 import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.query.timeseries.TimeseriesQuery;
 import org.apache.druid.segment.join.JoinType;
@@ -337,6 +338,73 @@ public class QueriesTest
     Assertions.assertEquals("Base 
dataSource[LookupDataSource{lookupName='lookyloo'}] is not a table!", 
e.getMessage());
   }
 
+  @Test
+  public void testNarrowToQueryCoveringQuery()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2000/2001"), "0", 0);
+    Assertions.assertSame(descriptor, Queries.narrowToQuery(descriptor, 
makeTimeseriesQuery("1999/2001", "2005/2006")));
+  }
+
+  @Test
+  public void testNarrowToQueryPartialOverlap()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2000/2001"), "0", 1);
+    Assertions.assertEquals(
+        new SegmentDescriptor(Intervals.of("2000-03/2001"), "0", 1),
+        Queries.narrowToQuery(descriptor, makeTimeseriesQuery("1998/1999", 
"2000-03/2002"))
+    );
+  }
+
+  @Test
+  public void testNarrowToQueryNoOverlap()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2000/2001"), "0", 0);
+    Assertions.assertNull(Queries.narrowToQuery(descriptor, 
makeTimeseriesQuery("1999/2000", "2001/2002")));
+  }
+
+  @Test
+  public void testNarrowToQuerySplitIsAnError()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2000/2001"), "0", 0);
+    final TimeseriesQuery query = makeTimeseriesQuery("1999/2000-03", 
"2000-06/2002");
+
+    DruidException e = assertThrows(DruidException.class, () -> 
Queries.narrowToQuery(descriptor, query));
+    Assertions.assertEquals(
+        "Query intervals[[1999-01-01T00:00:00.000Z/2000-03-01T00:00:00.000Z, "
+        + "2000-06-01T00:00:00.000Z/2002-01-01T00:00:00.000Z]] split segment 
interval"
+        + "[2000-01-01T00:00:00.000Z/2001-01-01T00:00:00.000Z]",
+        e.getMessage()
+    );
+  }
+
+  @Test
+  public void testNarrowToQuerySubQueryStack()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2000/2001"), "0", 0);
+    final TimeseriesQuery query =
+        Druids.newTimeseriesQueryBuilder()
+              .dataSource(new 
QueryDataSource(makeTimeseriesQuery("2000-03/2002")))
+              .intervals("1999/2000-02")
+              .granularity(Granularities.ALL)
+              .build();
+
+    Assertions.assertEquals(
+        new SegmentDescriptor(Intervals.of("2000-03/2001"), "0", 0),
+        Queries.narrowToQuery(descriptor, query)
+    );
+  }
+
+  private static TimeseriesQuery makeTimeseriesQuery(final String... intervals)
+  {
+    return Druids.newTimeseriesQueryBuilder()
+                 .dataSource("foo")
+                 .intervals(
+                     new 
MultipleIntervalSegmentSpec(Arrays.stream(intervals).map(Intervals::of).toList())
+                 )
+                 .granularity(Granularities.ALL)
+                 .build();
+  }
+
   @Test
   public void testWithBaseDataSourceBasic()
   {
diff --git 
a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryRunnerFactoryTest.java
 
b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryRunnerFactoryTest.java
index c7031f636a3..1e6c5ee183c 100644
--- 
a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryRunnerFactoryTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryRunnerFactoryTest.java
@@ -20,8 +20,10 @@
 package org.apache.druid.query.scan;
 
 import com.google.common.collect.ImmutableList;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.Pair;
 import org.apache.druid.java.util.common.UOE;
 import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.common.guava.Sequence;
@@ -34,11 +36,13 @@ import org.apache.druid.query.QueryRunner;
 import org.apache.druid.query.QueryRunnerTestHelper;
 import org.apache.druid.query.ResourceLimitExceededException;
 import org.apache.druid.query.SegmentDescriptor;
+import org.apache.druid.query.SinkQueryRunners;
 import org.apache.druid.query.context.ResponseContext;
 import org.apache.druid.query.spec.LegacySegmentSpec;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
 import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.query.spec.SpecificSegmentQueryRunner;
 import org.apache.druid.query.spec.SpecificSegmentSpec;
 import org.joda.time.Interval;
 import org.junit.jupiter.api.Assertions;
@@ -46,7 +50,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedClass;
 import org.junit.jupiter.params.provider.MethodSource;
 
-import java.io.IOException;
+import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -129,7 +133,7 @@ public class ScanQueryRunnerFactoryTest
     }
 
     @Test
-    public void testSortAndLimitScanResultValues() throws IOException
+    public void testSortAndLimitScanResultValues()
     {
       List<ScanResultValue> srvs = new ArrayList<>(numElements);
       List<Long> expectedEventTimestamps = new ArrayList<>();
@@ -153,12 +157,14 @@ public class ScanQueryRunnerFactoryTest
       Sequence<ScanResultValue> inputSequence = Sequences.simple(srvs);
       try {
         List<ScanResultValue> output = FACTORY.stableLimitingSort(
-            inputSequence,
-            query,
-            ImmutableList.of(new Interval(
-                DateTimes.of("2010-01-01"),
-                DateTimes.of("2019-01-01").plusHours(1)
-            ))
+            ImmutableList.of(
+                Pair.of(
+                    new Interval(DateTimes.of("2010-01-01"), 
DateTimes.of("2019-01-01").plusHours(1)),
+                    (queryPlus, responseContext) -> inputSequence
+                )
+            ),
+            QueryPlus.wrap(query),
+            ResponseContext.createEmpty()
         ).toList();
         if (query.getScanRowsLimit() > Integer.MAX_VALUE) {
           Assertions.fail("Unsupported exception should have been thrown due 
to high limit");
@@ -284,6 +290,10 @@ public class ScanQueryRunnerFactoryTest
 
   public static class ScanQueryRunnerFactoryNonParameterizedTest
   {
+    private static final Interval HOUR_0 = Intervals.of("2000-01-01T00/PT1H");
+    private static final Interval HOUR_1 = Intervals.of("2000-01-01T01/PT1H");
+    private static final Interval HOUR_2 = Intervals.of("2000-01-01T02/PT1H");
+
     private SegmentDescriptor descriptor = new SegmentDescriptor(new Interval(
         DateTimes.of("2010-01-01"),
         DateTimes.of("2019-01-01").plusHours(1)
@@ -373,5 +383,382 @@ public class ScanQueryRunnerFactoryTest
           ex.getMessage()
       );
     }
+
+    @Test
+    public void testPriorityQueueDescendingSkipsOlderIntervals()
+    {
+      final RecordingRunner hour0 = new RecordingRunner(HOUR_0, 10, 20, 30);
+      final RecordingRunner hour1 = new RecordingRunner(HOUR_1, 10, 20, 30);
+      final RecordingRunner hour2Partition0 = new RecordingRunner(HOUR_2, 10, 
20, 30);
+      final RecordingRunner hour2Partition1 = new RecordingRunner(HOUR_2, 15, 
25, 35);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_2, 35), minute(HOUR_2, 30), minute(HOUR_2, 25)),
+          runMerged(
+              Order.DESCENDING,
+              3,
+              List.of(HOUR_0, HOUR_1, HOUR_2, HOUR_2),
+              List.of(hour0, hour1, hour2Partition0, hour2Partition1)
+          )
+      );
+      Assertions.assertEquals(
+          List.of(false, false, true, true),
+          ranFlags(hour0, hour1, hour2Partition0, hour2Partition1)
+      );
+    }
+
+    @Test
+    public void testPriorityQueueDescendingRunsIntervalsUntilLimitReached()
+    {
+      final RecordingRunner hour0 = new RecordingRunner(HOUR_0, 10, 20, 30);
+      final RecordingRunner hour1 = new RecordingRunner(HOUR_1, 10, 20, 30);
+      final RecordingRunner hour2 = new RecordingRunner(HOUR_2, 10, 20);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_2, 20), minute(HOUR_2, 10), minute(HOUR_1, 30), 
minute(HOUR_1, 20)),
+          runMerged(Order.DESCENDING, 4, List.of(HOUR_0, HOUR_1, HOUR_2), 
List.of(hour0, hour1, hour2))
+      );
+      Assertions.assertEquals(List.of(false, true, true), ranFlags(hour0, 
hour1, hour2));
+    }
+
+    @Test
+    public void testPriorityQueueAscendingSkipsLaterIntervals()
+    {
+      final RecordingRunner hour0Partition0 = new RecordingRunner(HOUR_0, 10, 
20, 30);
+      final RecordingRunner hour0Partition1 = new RecordingRunner(HOUR_0, 15, 
25, 35);
+      final RecordingRunner hour1 = new RecordingRunner(HOUR_1, 10, 20, 30);
+      final RecordingRunner hour2 = new RecordingRunner(HOUR_2, 10, 20, 30);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_0, 10), minute(HOUR_0, 15), minute(HOUR_0, 20)),
+          runMerged(
+              Order.ASCENDING,
+              3,
+              List.of(HOUR_0, HOUR_0, HOUR_1, HOUR_2),
+              List.of(hour0Partition0, hour0Partition1, hour1, hour2)
+          )
+      );
+      Assertions.assertEquals(
+          List.of(true, true, false, false),
+          ranFlags(hour0Partition0, hour0Partition1, hour1, hour2)
+      );
+    }
+
+    @Test
+    public void testPriorityQueueAscendingNarrowsLaterRunners()
+    {
+      final RecordingRunner hour0Partition0 = new RecordingRunner(HOUR_0, 10, 
20, 30);
+      final RecordingRunner hour0Partition1 = new RecordingRunner(HOUR_0, 5, 
15, 25, 35);
+      final RecordingRunner hour1 = new RecordingRunner(HOUR_1, 10);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_0, 5), minute(HOUR_0, 10), minute(HOUR_0, 15)),
+          runMerged(
+              Order.ASCENDING,
+              3,
+              List.of(HOUR_0, HOUR_0, HOUR_1),
+              List.of(hour0Partition0, hour0Partition1, hour1)
+          )
+      );
+
+      // The second partition reads only up to the last row of the first, 
inclusive.
+      
Assertions.assertEquals(List.of(Intervals.of("2000-01-01T00/2000-01-01T02")), 
hour0Partition0.query.getIntervals());
+      Assertions.assertEquals(
+          List.of(Intervals.utc(HOUR_0.getStartMillis(), minute(HOUR_0, 30) + 
1)),
+          hour0Partition1.query.getIntervals()
+      );
+      Assertions.assertNull(hour1.query);
+    }
+
+    @Test
+    public void testPriorityQueueDescendingNarrowsLaterRunners()
+    {
+      final RecordingRunner hour0 = new RecordingRunner(HOUR_0, 50);
+      final RecordingRunner hour1Partition0 = new RecordingRunner(HOUR_1, 10, 
20, 30);
+      final RecordingRunner hour1Partition1 = new RecordingRunner(HOUR_1, 5, 
25, 40);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_1, 40), minute(HOUR_1, 30), minute(HOUR_1, 25)),
+          runMerged(
+              Order.DESCENDING,
+              3,
+              List.of(HOUR_0, HOUR_1, HOUR_1),
+              List.of(hour0, hour1Partition0, hour1Partition1)
+          )
+      );
+
+      // Runners run latest first, so the first partition reads only down to 
the last row of the second, inclusive.
+      
Assertions.assertEquals(List.of(Intervals.of("2000-01-01T00/2000-01-01T02")), 
hour1Partition1.query.getIntervals());
+      Assertions.assertEquals(
+          List.of(Intervals.utc(minute(HOUR_1, 5), HOUR_1.getEndMillis())),
+          hour1Partition0.query.getIntervals()
+      );
+      Assertions.assertNull(hour0.query);
+    }
+
+    @Test
+    public void testPriorityQueueRetainsEqualTimestampFromLaterRunner()
+    {
+      // Rows with equal timestamps are ordered by segment ID, so the later 
runner's row at minute 10 displaces the
+      // earlier runner's row at minute 10.
+      final RecordingRunner first = new RecordingRunner("b", HOUR_0, 0, 10);
+      final RecordingRunner second = new RecordingRunner("a", HOUR_0, 10, 20);
+
+      final List<ScanResultValue> rows =
+          runMergedRows(Order.ASCENDING, 2, List.of(HOUR_0, HOUR_0), 
List.of(first, second));
+
+      Assertions.assertEquals(
+          List.of("b", "a"),
+          
rows.stream().map(ScanResultValue::getSegmentId).collect(Collectors.toList())
+      );
+      Assertions.assertEquals(List.of(minute(HOUR_0, 0), minute(HOUR_0, 10)), 
toTimestamps(rows));
+      Assertions.assertEquals(
+          List.of(Intervals.utc(HOUR_0.getStartMillis(), minute(HOUR_0, 10) + 
1)),
+          second.query.getIntervals()
+      );
+    }
+
+    @Test
+    public void testPriorityQueueNarrowsSpecificSegmentDescriptors()
+    {
+      // Wrap runners in SpecificSegmentQueryRunner, as data servers do, so 
narrowed intervals reach each segment as a
+      // narrowed descriptor.
+      final List<SegmentDescriptor> descriptors = 
makeDescriptors(List.of(HOUR_0, HOUR_1, HOUR_1));
+      final List<RecordingRunner> recordingRunners = List.of(
+          new RecordingRunner(HOUR_0, 50),
+          new RecordingRunner(HOUR_1, 10, 40),
+          new RecordingRunner(HOUR_1, 20, 50)
+      );
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_1, 50), minute(HOUR_1, 40)),
+          runMerged(
+              Order.DESCENDING,
+              2,
+              List.of(HOUR_0, HOUR_1, HOUR_1),
+              IntStream.range(0, descriptors.size())
+                       .mapToObj(
+                           i -> (QueryRunner<ScanResultValue>) new 
SpecificSegmentQueryRunner<>(
+                               recordingRunners.get(i),
+                               new SpecificSegmentSpec(descriptors.get(i))
+                           )
+                       )
+                       .collect(Collectors.toList())
+          )
+      );
+
+      Assertions.assertNull(recordingRunners.get(0).query);
+      Assertions.assertEquals(
+          new MultipleSpecificSegmentSpec(
+              List.of(new SegmentDescriptor(Intervals.utc(minute(HOUR_1, 20), 
HOUR_1.getEndMillis()), "v0", 1))
+          ),
+          recordingRunners.get(1).query.getQuerySegmentSpec()
+      );
+      Assertions.assertEquals(
+          new MultipleSpecificSegmentSpec(List.of(descriptors.get(2))),
+          recordingRunners.get(2).query.getQuerySegmentSpec()
+      );
+    }
+
+    @Test
+    public void testRunnersThatDoNotMatchDescriptors()
+    {
+      // One fewer runner than descriptors, so runners cannot be matched to 
intervals.
+      final List<QueryRunner<ScanResultValue>> runners = List.of(
+          new RecordingRunner(HOUR_0, 10, 20, 30),
+          new RecordingRunner(HOUR_2, 10, 20, 30)
+      );
+      final List<Interval> descriptorIntervals = List.of(HOUR_0, HOUR_1, 
HOUR_2);
+
+      // Priority queue strategy.
+      Assertions.assertThrows(
+          DruidException.class,
+          () -> runMerged(Order.DESCENDING, 2, descriptorIntervals, runners)
+      );
+
+      // N-way merge strategy.
+      Assertions.assertThrows(
+          DruidException.class,
+          () -> runMerged(Order.DESCENDING, 
CONFIG.getMaxRowsQueuedForOrdering() + 1, descriptorIntervals, runners)
+      );
+    }
+
+    @Test
+    public void 
testPriorityQueueDescendingSinkQueryRunnersSkipsOlderIntervals()
+    {
+      final RecordingRunner hour0Hydrant0 = new RecordingRunner(HOUR_0, 10, 
20);
+      final RecordingRunner hour0Hydrant1 = new RecordingRunner(HOUR_0, 30);
+      final RecordingRunner hour1Hydrant0 = new RecordingRunner(HOUR_1, 10, 
30);
+      final RecordingRunner hour1Hydrant1 = new RecordingRunner(HOUR_1, 20);
+
+      Assertions.assertEquals(
+          List.of(minute(HOUR_1, 30), minute(HOUR_1, 20)),
+          runMerged(
+              Order.DESCENDING,
+              2,
+              List.of(HOUR_0, HOUR_1),
+              new SinkQueryRunners<>(
+                  List.of(
+                      Pair.of(HOUR_0, hour0Hydrant0),
+                      Pair.of(HOUR_0, hour0Hydrant1),
+                      Pair.of(HOUR_1, hour1Hydrant0),
+                      Pair.of(HOUR_1, hour1Hydrant1)
+                  )
+              )
+          )
+      );
+      Assertions.assertEquals(
+          List.of(false, false, true, true),
+          ranFlags(hour0Hydrant0, hour0Hydrant1, hour1Hydrant0, hour1Hydrant1)
+      );
+    }
+
+    @Test
+    public void testNWayMergeDescendingSinkQueryRunners()
+    {
+      Assertions.assertEquals(
+          List.of(
+              minute(HOUR_1, 30),
+              minute(HOUR_1, 20),
+              minute(HOUR_1, 10),
+              minute(HOUR_0, 30),
+              minute(HOUR_0, 20),
+              minute(HOUR_0, 10)
+          ),
+          runMerged(
+              Order.DESCENDING,
+              CONFIG.getMaxRowsQueuedForOrdering() + 1,
+              List.of(HOUR_0, HOUR_1),
+              new SinkQueryRunners<>(
+                  List.of(
+                      Pair.of(HOUR_0, new RecordingRunner(HOUR_0, 10, 20)),
+                      Pair.of(HOUR_0, new RecordingRunner(HOUR_0, 30)),
+                      Pair.of(HOUR_1, new RecordingRunner(HOUR_1, 10, 30)),
+                      Pair.of(HOUR_1, new RecordingRunner(HOUR_1, 20))
+                  )
+              )
+          )
+      );
+    }
+
+    private static List<Long> runMerged(
+        final Order order,
+        final long limit,
+        final List<Interval> descriptorIntervals,
+        final Iterable<QueryRunner<ScanResultValue>> runners
+    )
+    {
+      return toTimestamps(runMergedRows(order, limit, descriptorIntervals, 
runners));
+    }
+
+    private static List<ScanResultValue> runMergedRows(
+        final Order order,
+        final long limit,
+        final List<Interval> descriptorIntervals,
+        final Iterable<QueryRunner<ScanResultValue>> runners
+    )
+    {
+      final ScanQuery query =
+          Druids.newScanQueryBuilder()
+                .dataSource("foo")
+                .intervals(new 
MultipleSpecificSegmentSpec(makeDescriptors(descriptorIntervals)))
+                .resultFormat(ScanQuery.ResultFormat.RESULT_FORMAT_LIST)
+                .order(order)
+                .limit(limit)
+                .build();
+
+      return FACTORY.mergeRunners(Execs.directExecutor(), runners)
+                    .run(QueryPlus.wrap(query), ResponseContext.createEmpty())
+                    .toList()
+                    .stream()
+                    .flatMap(batch -> 
batch.toSingleEventScanResultValues().stream())
+                    .collect(Collectors.toList());
+    }
+
+    private static List<SegmentDescriptor> makeDescriptors(final 
List<Interval> intervals)
+    {
+      return IntStream.range(0, intervals.size())
+                      .mapToObj(i -> new SegmentDescriptor(intervals.get(i), 
"v0", i))
+                      .collect(Collectors.toList());
+    }
+
+    private static List<Long> toTimestamps(final List<ScanResultValue> rows)
+    {
+      return rows.stream()
+                 .map(row -> 
row.getFirstEventTimestamp(ScanQuery.ResultFormat.RESULT_FORMAT_LIST))
+                 .collect(Collectors.toList());
+    }
+
+    private static List<Boolean> ranFlags(final RecordingRunner... runners)
+    {
+      return Arrays.stream(runners).map(runner -> runner.query != 
null).collect(Collectors.toList());
+    }
+
+    private static long minute(final Interval hour, final int minute)
+    {
+      return hour.getStart().plusMinutes(minute).getMillis();
+    }
+
+    /**
+     * Runner that returns one single-row batch per timestamp within the query 
intervals, in the time order of the
+     * query, as a cursor would. Records the query it ran with.
+     */
+    private static class RecordingRunner implements 
QueryRunner<ScanResultValue>
+    {
+      @Nullable
+      private final String segmentId;
+      private final List<Long> timestamps;
+
+      /**
+       * Query this runner ran with, or null if it did not run.
+       */
+      @Nullable
+      private ScanQuery query;
+
+      RecordingRunner(final Interval hour, final int... minutes)
+      {
+        this(null, hour, minutes);
+      }
+
+      /**
+       * Creates a runner whose rows have {@code segmentId}, or the default of
+       * {@link ScanQueryTestHelper#generateScanResultValue} if null.
+       */
+      RecordingRunner(@Nullable final String segmentId, final Interval hour, 
final int... minutes)
+      {
+        this.segmentId = segmentId;
+        this.timestamps = Arrays.stream(minutes)
+                                .mapToObj(minute -> 
hour.getStart().plusMinutes(minute).getMillis())
+                                .collect(Collectors.toList());
+      }
+
+      @Override
+      public Sequence<ScanResultValue> run(
+          final QueryPlus<ScanResultValue> queryPlus,
+          final ResponseContext responseContext
+      )
+      {
+        query = (ScanQuery) queryPlus.getQuery();
+        final List<Interval> queryIntervals = query.getIntervals();
+        final List<Long> orderedTimestamps =
+            timestamps.stream()
+                      .filter(t -> queryIntervals.stream().anyMatch(interval 
-> interval.contains(t)))
+                      .collect(Collectors.toList());
+        if (Order.DESCENDING.equals(query.getTimeOrder())) {
+          Collections.reverse(orderedTimestamps);
+        }
+        return Sequences.simple(
+            orderedTimestamps.stream()
+                             .map(t -> makeRow(t, query.getResultFormat()))
+                             .collect(Collectors.toList())
+        );
+      }
+
+      private ScanResultValue makeRow(final long timestamp, final 
ScanQuery.ResultFormat resultFormat)
+      {
+        final ScanResultValue row = 
ScanQueryTestHelper.generateScanResultValue(timestamp, resultFormat, 1);
+        return segmentId == null ? row : new ScanResultValue(segmentId, 
row.getColumns(), row.getEvents());
+      }
+    }
   }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/query/spec/SpecificSegmentQueryRunnerTest.java
 
b/processing/src/test/java/org/apache/druid/query/spec/SpecificSegmentQueryRunnerTest.java
index 337a5652026..a11fe9a9626 100644
--- 
a/processing/src/test/java/org/apache/druid/query/spec/SpecificSegmentQueryRunnerTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/spec/SpecificSegmentQueryRunnerTest.java
@@ -31,6 +31,7 @@ import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.guava.Sequences;
 import org.apache.druid.java.util.common.guava.Yielder;
 import org.apache.druid.java.util.common.guava.YieldingAccumulator;
+import org.apache.druid.query.BaseQuery;
 import org.apache.druid.query.Druids;
 import org.apache.druid.query.QueryContext;
 import org.apache.druid.query.QueryPlus;
@@ -206,6 +207,47 @@ public class SpecificSegmentQueryRunnerTest
     assertThreadNameDuringProcessing(true, "processing_thread-name-query");
   }
 
+  @Test
+  public void testNarrowsDescriptorToQueryIntervals()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2012-01-01/P1D"), "version", 0);
+    final AtomicReference<QuerySegmentSpec> baseSegmentSpec = new 
AtomicReference<>();
+    final SpecificSegmentQueryRunner<Result<TimeseriesResultValue>> 
queryRunner = new SpecificSegmentQueryRunner<>(
+        (queryPlus, responseContext) -> {
+          baseSegmentSpec.set(((BaseQuery<?>) 
queryPlus.getQuery()).getQuerySegmentSpec());
+          return Sequences.empty();
+        },
+        new SpecificSegmentSpec(descriptor)
+    );
+
+    
queryRunner.run(QueryPlus.wrap(makeCountQuery("2011-12-31T12/2012-01-01T06")), 
ResponseContext.createEmpty())
+               .toList();
+
+    Assertions.assertEquals(
+        new MultipleSpecificSegmentSpec(
+            List.of(new 
SegmentDescriptor(Intervals.of("2012-01-01/2012-01-01T06"), "version", 0))
+        ),
+        baseSegmentSpec.get()
+    );
+  }
+
+  @Test
+  public void testSkipsSegmentOutsideQueryIntervals()
+  {
+    final SegmentDescriptor descriptor = new 
SegmentDescriptor(Intervals.of("2012-01-01/P1D"), "version", 0);
+    final SpecificSegmentQueryRunner<Result<TimeseriesResultValue>> 
queryRunner = new SpecificSegmentQueryRunner<>(
+        (queryPlus, responseContext) -> Assertions.fail("Segment should not 
run"),
+        new SpecificSegmentSpec(descriptor)
+    );
+
+    final ResponseContext responseContext = ResponseContext.createEmpty();
+    Assertions.assertEquals(
+        List.of(),
+        queryRunner.run(QueryPlus.wrap(makeCountQuery("2012-01-02/P1D")), 
responseContext).toList()
+    );
+    Assertions.assertNull(responseContext.getMissingSegments());
+  }
+
   private void assertThreadNameDuringProcessing(
       final Boolean setProcessingThreadNames,
       final String expectedThreadNameDuringProcessing
@@ -282,6 +324,16 @@ public class SpecificSegmentQueryRunnerTest
     }
   }
 
+  private static TimeseriesQuery makeCountQuery(final String interval)
+  {
+    return Druids.newTimeseriesQueryBuilder()
+                 .dataSource("foo")
+                 .granularity(Granularities.ALL)
+                 .intervals(ImmutableList.of(Intervals.of(interval)))
+                 .aggregators(ImmutableList.of(new 
CountAggregatorFactory("rows")))
+                 .build();
+  }
+
   private static Result<TimeseriesResultValue> makeResult()
   {
     final TimeseriesResultBuilder builder = new TimeseriesResultBuilder(
diff --git 
a/server/src/main/java/org/apache/druid/client/CachingQueryRunner.java 
b/server/src/main/java/org/apache/druid/client/CachingQueryRunner.java
index 41d4bb4ea63..d718254412d 100644
--- a/server/src/main/java/org/apache/druid/client/CachingQueryRunner.java
+++ b/server/src/main/java/org/apache/druid/client/CachingQueryRunner.java
@@ -31,6 +31,7 @@ import org.apache.druid.java.util.common.guava.BaseSequence;
 import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.guava.Sequences;
 import org.apache.druid.query.CacheStrategy;
+import org.apache.druid.query.Queries;
 import org.apache.druid.query.Query;
 import org.apache.druid.query.QueryPlus;
 import org.apache.druid.query.QueryRunner;
@@ -87,6 +88,14 @@ public class CachingQueryRunner<T> implements QueryRunner<T>
   {
     Query<T> query = queryPlus.getQuery();
     final CacheStrategy strategy = toolChest.getCacheStrategy(query, mapper);
+
+    // Key on the part of the segment that the query covers, since callers may 
narrow the query to skip part of it.
+    final SegmentDescriptor descriptor = 
Queries.narrowToQuery(segmentDescriptor, query);
+    if (descriptor == null) {
+      // Nothing worth caching, since the query does not overlap this segment.
+      return base.run(queryPlus, responseContext);
+    }
+
     final boolean populateCache = canPopulateCache(query, strategy);
     final boolean useCache = canUseCache(query, strategy);
 
@@ -94,7 +103,7 @@ public class CachingQueryRunner<T> implements QueryRunner<T>
     if (useCache || populateCache) {
       key = CacheUtil.computeSegmentCacheKey(
           cacheId,
-          alignToActualDataInterval(segmentDescriptor),
+          alignToActualDataInterval(descriptor),
           Bytes.concat(cacheKeyPrefix.get(), strategy.computeCacheKey(query))
       );
     } else {
diff --git a/server/src/main/java/org/apache/druid/server/ServerManager.java 
b/server/src/main/java/org/apache/druid/server/ServerManager.java
index a85e3e15874..3845f38a6f5 100644
--- a/server/src/main/java/org/apache/druid/server/ServerManager.java
+++ b/server/src/main/java/org/apache/druid/server/ServerManager.java
@@ -21,6 +21,7 @@ package org.apache.druid.server;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.inject.Inject;
 import org.apache.druid.client.CachingQueryRunner;
@@ -62,6 +63,7 @@ import org.apache.druid.query.context.ResponseContext;
 import org.apache.druid.query.metadata.metadata.SegmentMetadataQuery;
 import org.apache.druid.query.planning.ExecutionVertex;
 import org.apache.druid.query.policy.PolicyEnforcer;
+import org.apache.druid.query.scan.ScanQueryRunnerFactory;
 import org.apache.druid.query.spec.SpecificSegmentQueryRunner;
 import org.apache.druid.query.spec.SpecificSegmentSpec;
 import org.apache.druid.segment.Segment;
@@ -83,7 +85,9 @@ import org.joda.time.Interval;
 
 import javax.annotation.Nullable;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicLong;
@@ -410,32 +414,51 @@ public class ServerManager implements QuerySegmentWalker
     );
   }
 
-  protected <T> FunctionalIterable<QueryRunner<T>> getQueryRunnersForSegments(
-      final Query<T> query,
+  /**
+   * Returns one runner per spec, in the order of {@code specs}. The ordering 
is important because
+   * {@link QueryRunnerFactory} implementations may rely on it. For example,
+   * {@link ScanQueryRunnerFactory#mergeRunners} matches runners to segment 
descriptors positionally.
+   *
+   * <p>The order of {@code segmentReferences} need not match the order of 
{@code specs}.
+   */
+  private <T> FunctionalIterable<QueryRunner<T>> getQueryRunnersForSegments(
+      final List<SegmentDescriptor> specs,
+      final List<SegmentReference> segmentReferences,
       final QueryRunnerFactory<T, Query<T>> factory,
       final QueryToolChest<T, Query<T>> toolChest,
-      final List<SegmentReference> segmentReferences,
       final AtomicLong cpuTimeAccumulator,
       final Optional<byte[]> cacheKeyPrefix
   )
   {
+    final Map<SegmentDescriptor, SegmentReference> referencesByDescriptor = 
new HashMap<>();
+    for (final SegmentReference reference : segmentReferences) {
+      referencesByDescriptor.put(reference.getSegmentDescriptor(), reference);
+    }
+
     return FunctionalIterable
-        .create(segmentReferences)
+        .create(specs)
         .transform(
-            ref ->
-                ref.getSegmentReference()
-                   .map(segment ->
-                            buildQueryRunnerForSegment(
-                                ref.getSegmentDescriptor(),
-                                segment,
-                                factory,
-                                toolChest,
-                                cpuTimeAccumulator,
-                                cacheKeyPrefix
-                            )
-                   ).orElseThrow(
-                       () -> DruidException.defensive("Unexpected missing 
segment[%s]", ref.getSegmentDescriptor())
-                   )
+            spec -> {
+              final SegmentReference ref = referencesByDescriptor.get(spec);
+              if (ref == null) {
+                // Return nothing: ResourceManagingQueryRunner already 
reported this spec as missing.
+                return new NoopQueryRunner<>();
+              }
+
+              return ref.getSegmentReference()
+                        .map(segment ->
+                                 buildQueryRunnerForSegment(
+                                     ref.getSegmentDescriptor(),
+                                     segment,
+                                     factory,
+                                     toolChest,
+                                     cpuTimeAccumulator,
+                                     cacheKeyPrefix
+                                 )
+                        ).orElseThrow(
+                            () -> DruidException.defensive("Unexpected missing 
segment[%s]", ref.getSegmentDescriptor())
+                        );
+            }
         );
   }
 
@@ -558,7 +581,7 @@ public class ServerManager implements QuerySegmentWalker
     private final QueryRunnerFactory<T, Query<T>> factory;
     private final QueryToolChest<T, Query<T>> toolChest;
     private final ExecutionVertex ev;
-    private final Iterable<SegmentDescriptor> specs;
+    private final List<SegmentDescriptor> specs;
 
     public ResourceManagingQueryRunner(
         VersionedIntervalTimeline<String, DataSegment> timeline,
@@ -572,7 +595,8 @@ public class ServerManager implements QuerySegmentWalker
       this.factory = factory;
       this.toolChest = toolChest;
       this.ev = ev;
-      this.specs = specs;
+      // Copy, since run() iterates specs twice and both passes must see the 
same descriptors.
+      this.specs = ImmutableList.copyOf(specs);
     }
 
     @Override
@@ -607,10 +631,10 @@ public class ServerManager implements QuerySegmentWalker
         
queryPlus.getQueryMetrics().reportQueriedSegmentCount(segmentReferences.size()).emit(emitter);
 
         final FunctionalIterable<QueryRunner<T>> queryRunners = 
getQueryRunnersForSegments(
-            query,
+            specs,
+            segmentReferences,
             factory,
             toolChest,
-            segmentReferences,
             cpuTimeAccumulator,
             cacheKeyPrefix
         );
diff --git 
a/server/src/test/java/org/apache/druid/client/CachingQueryRunnerTest.java 
b/server/src/test/java/org/apache/druid/client/CachingQueryRunnerTest.java
index 3a4d1759db8..61e465b7fcb 100644
--- a/server/src/test/java/org/apache/druid/client/CachingQueryRunnerTest.java
+++ b/server/src/test/java/org/apache/druid/client/CachingQueryRunnerTest.java
@@ -214,6 +214,39 @@ public class CachingQueryRunnerTest extends 
InitializedNullHandlingTest
     }
   }
 
+  @Test
+  public void testQueryOnPartOfSegmentDoesNotUseWholeSegmentCacheEntry() 
throws IOException
+  {
+    final Query query = new TopNQueryBuilder()
+        .dataSource("ds")
+        .dimension("top_dim")
+        .metric("imps")
+        .threshold(3)
+        .intervals("2011-01-05/2011-01-10")
+        .aggregators(AGGS)
+        .granularity(Granularities.ALL)
+        .build();
+    final QueryToolChest toolchest = new TopNQueryQueryToolChest(new 
TopNQueryConfig());
+    final CacheStrategy cacheStrategy = toolchest.getCacheStrategy(query, 
null);
+    final byte[] cacheKeyPrefix = RandomUtils.nextBytes(10);
+
+    // Cache results under the key for the whole segment, as a query covering 
all of it would.
+    final Cache cache = MapCache.create(1024 * 1024);
+    cache.put(
+        CacheUtil.computeSegmentCacheKey(
+            CACHE_ID,
+            SEGMENT_DESCRIPTOR,
+            Bytes.concat(cacheKeyPrefix, cacheStrategy.computeCacheKey(query))
+        ),
+        toByteArray(
+            Iterables.transform(makeTopNResults(true, OBJECTS), 
cacheStrategy.prepareForSegmentLevelCache())
+        )
+    );
+
+    final CachingQueryRunner runner = makeCachingQueryRunner(cacheKeyPrefix, 
cache, toolchest, Sequences.empty());
+    Assertions.assertEquals(List.of(), 
runner.run(QueryPlus.wrap(query)).toList());
+  }
+
   @Test
   public void testNullCacheKeyPrefix()
   {
@@ -351,7 +384,7 @@ public class CachingQueryRunnerTest extends 
InitializedNullHandlingTest
     CacheStrategy cacheStrategy = toolchest.getCacheStrategy(query, null);
     Cache.NamedKey cacheKey = CacheUtil.computeSegmentCacheKey(
         CACHE_ID,
-        SEGMENT_DESCRIPTOR,
+        narrowSegmentDescriptor(query),
         Bytes.concat(keyPrefix, cacheStrategy.computeCacheKey(query))
     );
     Assertions.assertTrue(runner.canPopulateCache(query, cacheStrategy));
@@ -395,7 +428,7 @@ public class CachingQueryRunnerTest extends 
InitializedNullHandlingTest
     CacheStrategy cacheStrategy = toolchest.getCacheStrategy(query, null);
     Cache.NamedKey cacheKey = CacheUtil.computeSegmentCacheKey(
         CACHE_ID,
-        SEGMENT_DESCRIPTOR,
+        narrowSegmentDescriptor(query),
         Bytes.concat(cacheKeyPrefix, cacheStrategy.computeCacheKey(query))
     );
 
@@ -413,6 +446,19 @@ public class CachingQueryRunnerTest extends 
InitializedNullHandlingTest
     Assertions.assertEquals(expectedResults.toString(), results.toString());
   }
 
+  /**
+   * Returns {@link #SEGMENT_DESCRIPTOR} narrowed to the interval of {@code 
query}, which is where the runner keys its
+   * cache entries.
+   */
+  private static SegmentDescriptor narrowSegmentDescriptor(final Query<?> 
query)
+  {
+    return new SegmentDescriptor(
+        
Iterables.getOnlyElement(query.getIntervals()).overlap(SEGMENT_DESCRIPTOR.getInterval()),
+        SEGMENT_DESCRIPTOR.getVersion(),
+        SEGMENT_DESCRIPTOR.getPartitionNumber()
+    );
+  }
+
   private CachingQueryRunner makeCachingQueryRunner(
       byte[] cacheKeyPrefix,
       Cache cache,
diff --git 
a/server/src/test/java/org/apache/druid/server/coordination/ServerManagerScanOrderingTest.java
 
b/server/src/test/java/org/apache/druid/server/coordination/ServerManagerScanOrderingTest.java
new file mode 100644
index 00000000000..5eb856b95d8
--- /dev/null
+++ 
b/server/src/test/java/org/apache/druid/server/coordination/ServerManagerScanOrderingTest.java
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.server.coordination;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.testing.fieldbinder.Bind;
+import com.google.inject.testing.fieldbinder.BoundFieldModule;
+import org.apache.druid.client.cache.Cache;
+import org.apache.druid.client.cache.CacheConfig;
+import org.apache.druid.client.cache.CachePopulator;
+import org.apache.druid.client.cache.CachePopulatorStats;
+import org.apache.druid.client.cache.ForegroundCachePopulator;
+import org.apache.druid.client.cache.LocalCacheProvider;
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.MapBasedInputRow;
+import org.apache.druid.guice.annotations.Smile;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.concurrent.Execs;
+import org.apache.druid.java.util.emitter.EmittingLogger;
+import org.apache.druid.java.util.emitter.service.ServiceEmitter;
+import org.apache.druid.query.DefaultGenericQueryMetricsFactory;
+import org.apache.druid.query.DefaultQueryRunnerFactoryConglomerate;
+import org.apache.druid.query.Druids;
+import org.apache.druid.query.ForwardingQueryProcessingPool;
+import org.apache.druid.query.Order;
+import org.apache.druid.query.QueryPlus;
+import org.apache.druid.query.QueryProcessingPool;
+import org.apache.druid.query.QueryRunnerFactoryConglomerate;
+import org.apache.druid.query.SegmentDescriptor;
+import org.apache.druid.query.context.DefaultResponseContext;
+import org.apache.druid.query.policy.NoopPolicyEnforcer;
+import org.apache.druid.query.policy.PolicyEnforcer;
+import org.apache.druid.query.scan.ScanQuery;
+import org.apache.druid.query.scan.ScanQueryConfig;
+import org.apache.druid.query.scan.ScanQueryEngine;
+import org.apache.druid.query.scan.ScanQueryQueryToolChest;
+import org.apache.druid.query.scan.ScanQueryRunnerFactory;
+import org.apache.druid.query.scan.ScanResultValue;
+import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
+import org.apache.druid.segment.IncrementalIndexSegment;
+import org.apache.druid.segment.IndexBuilder;
+import org.apache.druid.segment.Segment;
+import org.apache.druid.segment.loading.AcquireMode;
+import org.apache.druid.server.SegmentManager;
+import org.apache.druid.server.ServerManager;
+import org.apache.druid.server.initialization.ServerConfig;
+import org.apache.druid.server.metrics.NoopServiceEmitter;
+import org.apache.druid.test.utils.TestSegmentCacheManager;
+import org.apache.druid.test.utils.TestSegmentManager;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.timeline.SegmentId;
+import org.joda.time.Interval;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Tests for time-ordered scan queries through {@link ServerManager} when some 
segments are loaded on demand.
+ */
+public class ServerManagerScanOrderingTest
+{
+  private static final ScanQuery.ResultFormat RESULT_FORMAT = 
ScanQuery.ResultFormat.RESULT_FORMAT_LIST;
+  private static final List<Interval> DAYS = List.of(
+      Intervals.of("2011-04-01/2011-04-02"),
+      Intervals.of("2011-04-02/2011-04-03"),
+      Intervals.of("2011-04-03/2011-04-04")
+  );
+  private static final int ROWS_PER_DAY = 3;
+
+  @Bind
+  private QueryRunnerFactoryConglomerate conglomerate;
+  @Bind
+  private SegmentManager segmentManager;
+  @Bind
+  private PolicyEnforcer policyEnforcer;
+  @Bind
+  private ServerConfig serverConfig;
+  @Bind
+  private ServiceEmitter serviceEmitter;
+  @Bind
+  private QueryProcessingPool queryProcessingPool;
+  @Bind
+  private CachePopulator cachePopulator;
+  @Bind
+  @Smile
+  private ObjectMapper objectMapper;
+  @Bind
+  private Cache cache;
+  @Bind
+  private CacheConfig cacheConfig;
+
+  @Inject
+  private ServerManager serverManager;
+
+  private OnDemandSegmentCacheManager cacheManager;
+  private ExecutorService exec;
+  private List<SegmentDescriptor> descriptors;
+  private List<DataSegment> dataSegments;
+
+  @BeforeEach
+  public void setUp() throws Exception
+  {
+    serviceEmitter = new NoopServiceEmitter();
+    EmittingLogger.registerEmitter(new NoopServiceEmitter());
+    cacheManager = new OnDemandSegmentCacheManager();
+    segmentManager = new SegmentManager(cacheManager);
+
+    descriptors = new ArrayList<>();
+    dataSegments = new ArrayList<>();
+    for (final Interval day : DAYS) {
+      final SegmentId segmentId = SegmentId.of("test", day, "1", 0);
+      final DataSegment dataSegment = 
TestSegmentManager.createDataSegmentForTest(segmentId);
+      final List<InputRow> rows = new ArrayList<>();
+      for (int hour = 0; hour < ROWS_PER_DAY; hour++) {
+        rows.add(
+            new MapBasedInputRow(
+                day.getStart().plusHours(hour),
+                List.of("dim"),
+                Map.of("dim", "x")
+            )
+        );
+      }
+      cacheManager.registerSegment(
+          dataSegment,
+          new 
IncrementalIndexSegment(IndexBuilder.create().rows(rows).buildIncrementalIndex(),
 segmentId)
+      );
+      segmentManager.loadSegment(dataSegment);
+      dataSegments.add(dataSegment);
+      descriptors.add(segmentId.toDescriptor());
+    }
+
+    conglomerate = 
DefaultQueryRunnerFactoryConglomerate.buildFromQueryRunnerFactories(
+        ImmutableMap.of(
+            ScanQuery.class,
+            new ScanQueryRunnerFactory(
+                new 
ScanQueryQueryToolChest(DefaultGenericQueryMetricsFactory.instance()),
+                new ScanQueryEngine(),
+                new ScanQueryConfig()
+            )
+        )
+    );
+    exec = Execs.multiThreaded(2, "ServerManagerScanOrderingTest-%d");
+    queryProcessingPool = new ForwardingQueryProcessingPool(exec);
+    cachePopulator = new ForegroundCachePopulator(new DefaultObjectMapper(), 
new CachePopulatorStats(), -1);
+    objectMapper = new DefaultObjectMapper();
+    cache = new LocalCacheProvider().get();
+    cacheConfig = new CacheConfig();
+    serverConfig = new ServerConfig();
+    policyEnforcer = NoopPolicyEnforcer.instance();
+
+    Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this);
+  }
+
+  @AfterEach
+  public void tearDown()
+  {
+    exec.shutdownNow();
+  }
+
+  @Test
+  public void testAscendingUnlimited_allCached()
+  {
+    Assertions.assertEquals(expectedTimes(Order.ASCENDING, Long.MAX_VALUE), 
runScan(Order.ASCENDING, Long.MAX_VALUE));
+  }
+
+  @Test
+  public void testAscendingUnlimited_firstDayOnDemand()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    Assertions.assertEquals(expectedTimes(Order.ASCENDING, Long.MAX_VALUE), 
runScan(Order.ASCENDING, Long.MAX_VALUE));
+  }
+
+  @Test
+  public void testDescendingUnlimited_firstDayOnDemand()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    Assertions.assertEquals(
+        expectedTimes(Order.DESCENDING, Long.MAX_VALUE),
+        runScan(Order.DESCENDING, Long.MAX_VALUE)
+    );
+  }
+
+  @Test
+  public void testAscendingLimited_allCached()
+  {
+    Assertions.assertEquals(expectedTimes(Order.ASCENDING, 5), 
runScan(Order.ASCENDING, 5));
+  }
+
+  @Test
+  public void testAscendingLimited_firstDayOnDemand()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    Assertions.assertEquals(expectedTimes(Order.ASCENDING, 5), 
runScan(Order.ASCENDING, 5));
+  }
+
+  @Test
+  public void testDescendingLimited_firstDayOnDemand()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    Assertions.assertEquals(expectedTimes(Order.DESCENDING, 5), 
runScan(Order.DESCENDING, 5));
+  }
+
+  @Test
+  public void testAscendingUnlimited_missingSegment()
+  {
+    final SegmentDescriptor missing = new SegmentDescriptor(DAYS.get(1), 
"unknown_version", 0);
+    descriptors.add(1, missing);
+    final DefaultResponseContext responseContext = 
DefaultResponseContext.createEmpty();
+    Assertions.assertEquals(
+        expectedTimes(Order.ASCENDING, Long.MAX_VALUE),
+        runScan(Order.ASCENDING, Long.MAX_VALUE, responseContext)
+    );
+    Assertions.assertEquals(List.of(missing), 
responseContext.getMissingSegments());
+  }
+
+  @Test
+  public void testDescendingLimited_missingSegment()
+  {
+    final SegmentDescriptor missing = new SegmentDescriptor(DAYS.get(1), 
"unknown_version", 0);
+    descriptors.add(1, missing);
+    final DefaultResponseContext responseContext = 
DefaultResponseContext.createEmpty();
+    Assertions.assertEquals(expectedTimes(Order.DESCENDING, 5), 
runScan(Order.DESCENDING, 5, responseContext));
+    Assertions.assertEquals(List.of(missing), 
responseContext.getMissingSegments());
+  }
+
+  @Test
+  public void testAscendingUnlimited_onDemandAndMissingSegments()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    final SegmentDescriptor missing = new SegmentDescriptor(DAYS.get(1), 
"unknown_version", 0);
+    descriptors.add(1, missing);
+    final DefaultResponseContext responseContext = 
DefaultResponseContext.createEmpty();
+    Assertions.assertEquals(
+        expectedTimes(Order.ASCENDING, Long.MAX_VALUE),
+        runScan(Order.ASCENDING, Long.MAX_VALUE, responseContext)
+    );
+    Assertions.assertEquals(List.of(missing), 
responseContext.getMissingSegments());
+  }
+
+  @Test
+  public void testDescendingLimited_onDemandAndMissingSegments()
+  {
+    cacheManager.evictFromCache(dataSegments.get(0));
+    final SegmentDescriptor missing = new SegmentDescriptor(DAYS.get(1), 
"unknown_version", 0);
+    descriptors.add(1, missing);
+    final DefaultResponseContext responseContext = 
DefaultResponseContext.createEmpty();
+    Assertions.assertEquals(expectedTimes(Order.DESCENDING, 5), 
runScan(Order.DESCENDING, 5, responseContext));
+    Assertions.assertEquals(List.of(missing), 
responseContext.getMissingSegments());
+  }
+
+  private List<Long> runScan(final Order order, final long limit)
+  {
+    return runScan(order, limit, DefaultResponseContext.createEmpty());
+  }
+
+  private List<Long> runScan(final Order order, final long limit, final 
DefaultResponseContext responseContext)
+  {
+    final ScanQuery query = Druids.newScanQueryBuilder()
+                                  .dataSource("test")
+                                  .intervals(new 
MultipleSpecificSegmentSpec(descriptors))
+                                  .columns("__time", "dim")
+                                  .resultFormat(RESULT_FORMAT)
+                                  .order(order)
+                                  .limit(limit)
+                                  .batchSize(1)
+                                  .build();
+    final List<ScanResultValue> results = 
serverManager.getQueryRunnerForSegments(query, descriptors)
+                                                       
.run(QueryPlus.wrap(query), responseContext)
+                                                       .toList();
+    final List<Long> times = new ArrayList<>();
+    for (final ScanResultValue result : results) {
+      for (final ScanResultValue single : 
result.toSingleEventScanResultValues()) {
+        times.add(single.getFirstEventTimestamp(RESULT_FORMAT));
+      }
+    }
+    return times;
+  }
+
+  private static List<Long> expectedTimes(final Order order, final long limit)
+  {
+    final List<Long> times = new ArrayList<>();
+    for (final Interval day : DAYS) {
+      for (int hour = 0; hour < ROWS_PER_DAY; hour++) {
+        times.add(day.getStart().plusHours(hour).getMillis());
+      }
+    }
+    if (order == Order.DESCENDING) {
+      Collections.reverse(times);
+    }
+    return new ArrayList<>(times.subList(0, (int) Math.min(limit, 
times.size())));
+  }
+
+  /**
+   * Cache manager that can report specific segments as absent from the cache 
but loadable on demand.
+   */
+  private static class OnDemandSegmentCacheManager extends 
TestSegmentCacheManager
+  {
+    private final Set<SegmentId> evicted = new HashSet<>();
+
+    void evictFromCache(final DataSegment dataSegment)
+    {
+      evicted.add(dataSegment.getId());
+    }
+
+    @Override
+    public Optional<Segment> acquireCachedSegment(final SegmentId segmentId, 
final AcquireMode acquireMode)
+    {
+      if (evicted.contains(segmentId)) {
+        return Optional.empty();
+      }
+      return super.acquireCachedSegment(segmentId, acquireMode);
+    }
+
+    @Override
+    public boolean canLoadSegmentsOnDemand()
+    {
+      return true;
+    }
+
+    @Override
+    public boolean canLoadSegmentOnDemand(final DataSegment dataSegment)
+    {
+      return true;
+    }
+  }
+}


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

Reply via email to