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 b4882aa503a perf: Descending vector cursors. (#19947)
b4882aa503a is described below

commit b4882aa503a9b78391e33ac63c6ea9af9780e294
Author: Gian Merlino <[email protected]>
AuthorDate: Mon Aug 24 12:20:38 2026 -0700

    perf: Descending vector cursors. (#19947)
    
    This patch adds descending-order vector cursors, enabling faster
    execution for timeseries, timeBoundary, and scan queries that run in
    descending order.
---
 .../druid/benchmark/query/TimeseriesBenchmark.java |  21 +-
 docs/querying/query-context-reference.md           |  11 +-
 .../querykit/scan/ScanQueryFrameProcessorTest.java | 197 +++++-----
 .../query/vector/VectorCursorGranularizer.java     |  52 ++-
 .../org/apache/druid/segment/BitmapOffset.java     |   9 +-
 .../druid/segment/QueryableIndexCursorHolder.java  |  58 ++-
 .../apache/druid/segment/data/ColumnarDoubles.java |   5 +-
 .../apache/druid/segment/data/ColumnarFloats.java  |   5 +-
 .../apache/druid/segment/data/ColumnarLongs.java   |   5 +-
 .../nested/NestedFieldDictionaryEncodedColumn.java |  15 +-
 .../druid/segment/nested/ScalarDoubleColumn.java   |   5 +-
 .../druid/segment/nested/ScalarLongColumn.java     |   5 +-
 .../apache/druid/segment/nested/VariantColumn.java |   5 +-
 .../vector/DescendingBitmapVectorOffset.java       | 154 ++++++++
 .../vector/DescendingNoFilterVectorOffset.java     | 107 +++++
 .../druid/segment/vector/ReadableVectorOffset.java |   8 +-
 .../vector/ReverseVectorColumnSelectorFactory.java | 434 +++++++++++++++++++++
 .../apache/druid/query/QueryRunnerTestHelper.java  |  65 ---
 .../timeboundary/TimeBoundaryQueryRunnerTest.java  | 255 +++++-------
 .../timeseries/TimeseriesQueryRunnerTest.java      |   4 +-
 .../segment/DescendingVectorCursorDiffTest.java    | 306 +++++++++++++++
 .../java/org/apache/druid/segment/TestHelper.java  |  10 -
 .../segment/vector/DescendingVectorOffsetTest.java | 253 ++++++++++++
 .../druid/sql/calcite/CalciteJoinQueryTest.java    |   6 +-
 .../apache/druid/sql/calcite/CalciteQueryTest.java |   6 -
 .../sql/calcite/CalciteTimeBoundaryQueryTest.java  |   2 +-
 26 files changed, 1603 insertions(+), 400 deletions(-)

diff --git 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/TimeseriesBenchmark.java
 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/TimeseriesBenchmark.java
index 0ddb941ea0b..54bfca56f86 100644
--- 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/TimeseriesBenchmark.java
+++ 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/TimeseriesBenchmark.java
@@ -28,6 +28,7 @@ import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.common.granularity.Granularity;
 import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.math.expr.ExpressionProcessing;
 import org.apache.druid.query.Druids;
 import org.apache.druid.query.FinalizeResultsQueryRunner;
 import org.apache.druid.query.Query;
@@ -99,7 +100,17 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
 
 @State(Scope.Benchmark)
-@Fork(value = 1)
+@Fork(
+    value = 1,
+    jvmArgsAppend = {
+        "--add-exports=java.base/sun.nio.ch=ALL-UNNAMED",
+        "--add-opens=java.base/java.nio=ALL-UNNAMED",
+        "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED",
+        "--add-opens=java.base/java.lang=ALL-UNNAMED",
+        "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED",
+        "--add-opens=java.base/jdk.internal.ref=ALL-UNNAMED"
+    }
+)
 @Warmup(iterations = 5)
 @Measurement(iterations = 15)
 public class TimeseriesBenchmark
@@ -113,6 +124,9 @@ public class TimeseriesBenchmark
   @Param({"true", "false"})
   private boolean descending;
 
+  @Param({"false", "true"})
+  private String vectorize;
+
   @Param({"all", "hour"})
   private String queryGranularity;
 
@@ -164,6 +178,7 @@ public class TimeseriesBenchmark
                 .intervals(intervalSpec)
                 .aggregators(queryAggs)
                 .descending(descending)
+                .context(Map.of("vectorize", vectorize))
                 .build();
 
       basicQueries.put("A", queryA);
@@ -184,6 +199,7 @@ public class TimeseriesBenchmark
                 .intervals(intervalSpec)
                 .aggregators(queryAggs)
                 .descending(descending)
+                .context(Map.of("vectorize", vectorize))
                 .build();
 
       basicQueries.put("timeFilterNumeric", timeFilterQuery);
@@ -204,6 +220,7 @@ public class TimeseriesBenchmark
                 .intervals(intervalSpec)
                 .aggregators(queryAggs)
                 .descending(descending)
+                .context(Map.of("vectorize", vectorize))
                 .build();
 
       basicQueries.put("timeFilterAlphanumeric", timeFilterQuery);
@@ -221,6 +238,7 @@ public class TimeseriesBenchmark
                 .intervals(intervalSpec)
                 .aggregators(queryAggs)
                 .descending(descending)
+                .context(Map.of("vectorize", vectorize))
                 .build();
 
       basicQueries.put("timeFilterByInterval", timeFilterQuery);
@@ -238,6 +256,7 @@ public class TimeseriesBenchmark
   {
     log.info("SETUP CALLED AT " + System.currentTimeMillis());
 
+    ExpressionProcessing.initializeForTests();
     ComplexMetrics.registerSerde(HyperUniquesSerde.TYPE_NAME, new 
HyperUniquesSerde());
 
     setupQueries();
diff --git a/docs/querying/query-context-reference.md 
b/docs/querying/query-context-reference.md
index d37d2b80799..3006869f175 100644
--- a/docs/querying/query-context-reference.md
+++ b/docs/querying/query-context-reference.md
@@ -107,9 +107,9 @@ query page.
 
 ## Vectorization parameters
 
-The GroupBy and Timeseries query types can run in _vectorized_ mode, which 
speeds up query execution by processing
-batches of rows at a time. Not all queries can be vectorized. In particular, 
vectorization currently has the following
-requirements:
+The GroupBy, Timeseries, TimeBoundary, and Scan (in MSQ only) query types can 
run in _vectorized_ mode, which speeds up
+query execution by processing batches of rows at a time. Not all queries can 
be vectorized. In particular, vectorization
+currently has the following requirements:
 
 - All query-level filters must either be able to run on bitmap indexes or must 
offer vectorized row-matchers. These
 include `selector`, `bound`, `in`, `like`, `regex`, `search`, `and`, `or`, and 
`not`.
@@ -120,16 +120,15 @@ include `selector`, `bound`, `in`, `like`, `regex`, 
`search`, `and`, `or`, and `
 - All virtual columns must offer vectorized implementations. Currently for 
expression virtual columns, support for vectorization is decided on a per 
expression basis, depending on the type of input and the functions used by the 
expression. See the currently supported list in the [expression 
documentation](math-expr.md#vectorization-support).
 - For GroupBy: All dimension specs must be "default" (no extraction functions 
or filtered dimension specs).
 - For GroupBy: No multi-value dimensions.
-- For Timeseries: No "descending" order.
 - Only immutable segments (not real-time).
 - Only [table datasources](datasource.md#table) (not joins, subqueries, 
lookups, or inline datasources).
 
-Other query types (like TopN, Scan, Select, and Search) ignore the `vectorize` 
parameter, and will execute without
+Other query types (like TopN, Search, and native Scan) ignore the `vectorize` 
parameter, and will execute without
 vectorization. These query types will ignore the `vectorize` parameter even if 
it is set to `"force"`.
 
 |Parameter|Default| Description|
 |---------|-------|------------|
-|`vectorize`|`true`|Enables or disables vectorized query execution. Possible 
values are `false` (disabled), `true` (enabled if possible, disabled otherwise, 
on a per-segment basis), and `force` (enabled, and groupBy or timeseries 
queries that cannot be vectorized will fail). The `"force"` setting is meant to 
aid in testing, and is not generally useful in production (since real-time 
segments can never be processed with vectorized execution, any queries on 
real-time data will fail). This w [...]
+|`vectorize`|`true`|Enables or disables vectorized query execution. Possible 
values are `false` (disabled), `true` (enabled if possible, disabled otherwise, 
on a per-segment basis), and `force` (enabled, and query types that support 
vectorization will fail if they cannot be vectorized). The `"force"` setting is 
meant to aid in testing, and is not generally useful in production (since 
real-time segments can never be processed with vectorized execution, any 
queries on real-time data will f [...]
 |`vectorSize`|`512`|Sets the row batching size for a particular query. This 
will override `druid.query.default.context.vectorSize` if it's set.|
 |`vectorizeVirtualColumns`|`true`|Enables or disables vectorized query 
processing of queries with virtual columns, layered on top of `vectorize` 
(`vectorize` must also be set to true for a query to utilize vectorization). 
Possible values are `false` (disabled), `true` (enabled if possible, disabled 
otherwise, on a per-segment basis), and `force` (enabled, and groupBy or 
timeseries queries with virtual columns that cannot be vectorized will fail). 
The `"force"` setting is meant to aid in  [...]
 
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/scan/ScanQueryFrameProcessorTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/scan/ScanQueryFrameProcessorTest.java
index f3500915f96..d72b4bdfb30 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/scan/ScanQueryFrameProcessorTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/scan/ScanQueryFrameProcessorTest.java
@@ -20,6 +20,7 @@
 package org.apache.druid.msq.querykit.scan;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.ListenableFuture;
 import org.apache.druid.collections.ReferenceCountingResourceHolder;
 import org.apache.druid.collections.ResourceHolder;
@@ -40,11 +41,14 @@ import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.Unit;
 import org.apache.druid.java.util.common.guava.Sequence;
+import org.apache.druid.java.util.common.guava.Sequences;
 import org.apache.druid.msq.querykit.FrameProcessorTestBase;
 import org.apache.druid.msq.querykit.ReadableInput;
 import org.apache.druid.msq.querykit.SegmentReferenceHolder;
 import org.apache.druid.msq.test.LimitedFrameWriterFactory;
 import org.apache.druid.query.Druids;
+import org.apache.druid.query.Order;
+import org.apache.druid.query.QueryContexts;
 import org.apache.druid.query.policy.PolicyEnforcer;
 import org.apache.druid.query.scan.ScanQuery;
 import org.apache.druid.query.scan.ScanQueryEngine;
@@ -71,8 +75,6 @@ import org.junit.jupiter.api.Test;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
-
-import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -103,74 +105,44 @@ public class ScanQueryFrameProcessorTest extends 
FrameProcessorTestBase
               .columns(cursorFactory.getRowSignature().getColumnNames())
               .build();
 
-    final BlockingQueueFrameChannel outputChannel = 
BlockingQueueFrameChannel.minimal();
-
-    // Limit output frames to 1 row to ensure we test edge cases
-    final FrameWriterFactory frameWriterFactory = new 
LimitedFrameWriterFactory(
-        FrameWriters.makeFrameWriterFactory(
-            FrameType.latestRowBased(),
-            new SingleMemoryAllocatorFactory(HeapMemoryAllocator.unlimited()),
-            cursorFactory.getRowSignature(),
-            Collections.emptyList(),
-            false
-        ),
-        1
-    );
-
-    final ReferenceCountedSegmentProvider segmentReferenceProvider =
-        new ReferenceCountedSegmentProvider(new 
QueryableIndexSegment(queryableIndex, SegmentId.dummy("test")));
-    Assertions.assertEquals(0, segmentReferenceProvider.getNumReferences());
-    final ScanQueryFrameProcessor processor = new ScanQueryFrameProcessor(
-        query,
-        null,
-        new DefaultObjectMapper(),
-        ReadableInput.segment(
-            new SegmentReferenceHolder(
-                new SegmentReference(
-                    SegmentId.dummy("test").toDescriptor(),
-                    segmentReferenceProvider.acquireReference(),
-                    null
-                ),
-                null
-            )
-        ),
-        SegmentMapFunction.IDENTITY,
-        new ResourceHolder<>()
-        {
-          @Override
-          public WritableFrameChannel get()
-          {
-            return outputChannel.writable();
-          }
-
-          @Override
-          public void close()
-          {
-            try {
-              outputChannel.writable().close();
-            }
-            catch (IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        },
-        new ReferenceCountingResourceHolder<>(frameWriterFactory, () -> {})
-    );
-
-    ListenableFuture<Object> retVal = exec.runFully(processor, null);
-
-    final Sequence<List<Object>> rowsFromProcessor = 
FrameTestUtil.readRowsFromFrameChannel(
-        outputChannel.readable(),
-        FrameReader.create(cursorFactory.getRowSignature())
-    );
-
     FrameTestUtil.assertRowsEqual(
         FrameTestUtil.readRowsFromCursorFactory(cursorFactory, 
cursorFactory.getRowSignature(), false),
-        rowsFromProcessor
+        Sequences.simple(runScanOverSegment(queryableIndex, query))
     );
+  }
 
-    Assertions.assertEquals(Unit.instance(), retVal.get());
-    Assertions.assertEquals(0, segmentReferenceProvider.getNumReferences()); 
// Segment reference was closed
+  @Test
+  public void test_runWithSegments_descendingMatchesNonVectorized() throws 
Exception
+  {
+    final QueryableIndex queryableIndex = TestIndex.getMMappedTestIndex();
+
+    final Druids.ScanQueryBuilder baseBuilder =
+        Druids.newScanQueryBuilder()
+              .dataSource("test")
+              .intervals(new 
MultipleIntervalSegmentSpec(ImmutableList.of(Intervals.of("2000-01-01T00Z/2030-01-01T00Z"))))
+              .columns("__time", "market", "quality", "index")
+              .order(Order.DESCENDING);
+
+    final ScanQuery nonVectorizedQuery =
+        baseBuilder.context(ImmutableMap.of(QueryContexts.VECTORIZE_KEY, 
"false")).build();
+    final ScanQuery vectorizedQuery =
+        baseBuilder.context(ImmutableMap.of(QueryContexts.VECTORIZE_KEY, 
"force", QueryContexts.VECTOR_SIZE_KEY, 7))
+                   .build();
+
+    final List<List<Object>> nonVectorizedRows = 
runScanOverSegment(queryableIndex, nonVectorizedQuery);
+    final List<List<Object>> vectorizedRows = 
runScanOverSegment(queryableIndex, vectorizedQuery);
+
+    // The vectorized descending scan must match the non-vectorized descending 
scan exactly.
+    Assertions.assertFalse(vectorizedRows.isEmpty());
+    Assertions.assertEquals(nonVectorizedRows, vectorizedRows);
+
+    // Sanity check: __time (column 0) is non-increasing.
+    long previousTime = Long.MAX_VALUE;
+    for (final List<Object> row : vectorizedRows) {
+      final long time = ((Number) row.getFirst()).longValue();
+      Assertions.assertTrue(time <= previousTime, "descending __time");
+      previousTime = time;
+    }
   }
 
   @Test
@@ -229,25 +201,7 @@ public class ScanQueryFrameProcessorTest extends 
FrameProcessorTestBase
         new DefaultObjectMapper(),
         ReadableInput.channel(inputChannel.readable(), 
FrameReader.create(signature), 0, 0),
         SegmentMapFunction.IDENTITY,
-        new ResourceHolder<>()
-        {
-          @Override
-          public WritableFrameChannel get()
-          {
-            return outputChannel.writable();
-          }
-
-          @Override
-          public void close()
-          {
-            try {
-              outputChannel.writable().close();
-            }
-            catch (IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        },
+        ResourceHolder.fromCloseable(outputChannel.writable()),
         new ReferenceCountingResourceHolder<>(frameWriterFactory, () -> {})
     );
 
@@ -397,25 +351,7 @@ public class ScanQueryFrameProcessorTest extends 
FrameProcessorTestBase
             )
         ),
         SegmentMapFunction.IDENTITY,
-        new ResourceHolder<>()
-        {
-          @Override
-          public WritableFrameChannel get()
-          {
-            return outputChannel.writable();
-          }
-
-          @Override
-          public void close()
-          {
-            try {
-              outputChannel.writable().close();
-            }
-            catch (IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        },
+        ResourceHolder.fromCloseable(outputChannel.writable()),
         new ReferenceCountingResourceHolder<>(frameWriterFactory, () -> {})
     );
 
@@ -441,4 +377,57 @@ public class ScanQueryFrameProcessorTest extends 
FrameProcessorTestBase
 
     Assertions.assertEquals(Unit.instance(), retVal.get(30, TimeUnit.SECONDS));
   }
+
+  private List<List<Object>> runScanOverSegment(final QueryableIndex 
queryableIndex, final ScanQuery query)
+      throws Exception
+  {
+    final CursorFactory cursorFactory = new 
QueryableIndexCursorFactory(queryableIndex);
+    final RowSignature signature = cursorFactory.getRowSignature();
+
+    final BlockingQueueFrameChannel outputChannel = 
BlockingQueueFrameChannel.minimal();
+
+    // Limit output frames to 1 row to ensure we test edge cases
+    final FrameWriterFactory frameWriterFactory = new 
LimitedFrameWriterFactory(
+        FrameWriters.makeFrameWriterFactory(
+            FrameType.latestRowBased(),
+            new SingleMemoryAllocatorFactory(HeapMemoryAllocator.unlimited()),
+            signature,
+            Collections.emptyList(),
+            false
+        ),
+        1
+    );
+
+    final ReferenceCountedSegmentProvider segmentReferenceProvider =
+        new ReferenceCountedSegmentProvider(new 
QueryableIndexSegment(queryableIndex, SegmentId.dummy("test")));
+    final ScanQueryFrameProcessor processor = new ScanQueryFrameProcessor(
+        query,
+        null,
+        new DefaultObjectMapper(),
+        ReadableInput.segment(
+            new SegmentReferenceHolder(
+                new SegmentReference(
+                    SegmentId.dummy("test").toDescriptor(),
+                    segmentReferenceProvider.acquireReference(),
+                    null
+                ),
+                null
+            )
+        ),
+        SegmentMapFunction.IDENTITY,
+        ResourceHolder.fromCloseable(outputChannel.writable()),
+        new ReferenceCountingResourceHolder<>(frameWriterFactory, () -> {})
+    );
+
+    final ListenableFuture<Object> retVal = exec.runFully(processor, null);
+
+    final List<List<Object>> rows = FrameTestUtil.readRowsFromFrameChannel(
+        outputChannel.readable(),
+        FrameReader.create(signature)
+    ).toList();
+
+    Assertions.assertEquals(Unit.instance(), retVal.get());
+    Assertions.assertEquals(0, segmentReferenceProvider.getNumReferences()); 
// Segment reference was closed
+    return rows;
+  }
 }
diff --git 
a/processing/src/main/java/org/apache/druid/query/vector/VectorCursorGranularizer.java
 
b/processing/src/main/java/org/apache/druid/query/vector/VectorCursorGranularizer.java
index f262a4ea486..164be369001 100644
--- 
a/processing/src/main/java/org/apache/druid/query/vector/VectorCursorGranularizer.java
+++ 
b/processing/src/main/java/org/apache/druid/query/vector/VectorCursorGranularizer.java
@@ -19,7 +19,9 @@
 
 package org.apache.druid.query.vector;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.java.util.common.granularity.Granularity;
@@ -75,7 +77,11 @@ public class VectorCursorGranularizer
       return null;
     }
 
-    final Iterable<Interval> bucketIterable = 
granularity.getIterable(clippedQueryInterval);
+    Iterable<Interval> bucketIterable = 
granularity.getIterable(clippedQueryInterval);
+    if (timeOrder == Order.DESCENDING) {
+      // Descending cursors emit rows latest-first, so iterate the buckets 
latest-first as well.
+      bucketIterable = Lists.reverse(ImmutableList.copyOf(bucketIterable));
+    }
     final Interval firstBucket = 
granularity.bucket(clippedQueryInterval.getStart());
 
     final VectorValueSelector timeSelector;
@@ -88,7 +94,7 @@ public class VectorCursorGranularizer
       timeSelector = 
cursor.getColumnSelectorFactory().makeValueSelector(ColumnHolder.TIME_COLUMN_NAME);
     }
 
-    return new VectorCursorGranularizer(cursor, bucketIterable, timeSelector);
+    return new VectorCursorGranularizer(cursor, bucketIterable, timeSelector, 
timeOrder == Order.DESCENDING);
   }
 
   private final VectorCursor cursor;
@@ -100,6 +106,9 @@ public class VectorCursorGranularizer
   @Nullable
   private final VectorValueSelector timeSelector;
 
+  // Whether the cursor (and hence the timestamps within each vector) is 
time-descending.
+  private final boolean descending;
+
   // Current time vector.
   @Nullable
   private long[] timestamps = null;
@@ -113,12 +122,14 @@ public class VectorCursorGranularizer
   private VectorCursorGranularizer(
       VectorCursor cursor,
       Iterable<Interval> bucketIterable,
-      @Nullable VectorValueSelector timeSelector
+      @Nullable VectorValueSelector timeSelector,
+      boolean descending
   )
   {
     this.cursor = cursor;
     this.bucketIterable = bucketIterable;
     this.timeSelector = timeSelector;
+    this.descending = descending;
   }
 
   public void setCurrentOffsets(final Interval bucketInterval)
@@ -134,16 +145,31 @@ public class VectorCursorGranularizer
         timestamps = timeSelector.getLongVector();
       }
 
-      // Skip "offset" to start of bucketInterval.
-      while (startOffset < vectorSize && timestamps[startOffset] < timeStart) {
-        startOffset++;
-      }
-
-      // Find end of bucketInterval.
-      for (endOffset = vectorSize - 1;
-           endOffset >= startOffset && timestamps[endOffset] >= timeEnd;
-           endOffset--) {
-        // nothing needed, "for" is doing the work.
+      if (descending) {
+        // Timestamps within the vector are descending. Skip rows at or after 
the (exclusive) end of the bucket; these
+        // belong to later buckets, which have already been processed.
+        while (startOffset < vectorSize && timestamps[startOffset] >= timeEnd) 
{
+          startOffset++;
+        }
+
+        // Find end of bucketInterval: the first row (from the back) whose 
timestamp is still within the bucket.
+        for (endOffset = vectorSize - 1;
+             endOffset >= startOffset && timestamps[endOffset] < timeStart;
+             endOffset--) {
+          // nothing needed, "for" is doing the work.
+        }
+      } else {
+        // Skip "offset" to start of bucketInterval.
+        while (startOffset < vectorSize && timestamps[startOffset] < 
timeStart) {
+          startOffset++;
+        }
+
+        // Find end of bucketInterval.
+        for (endOffset = vectorSize - 1;
+             endOffset >= startOffset && timestamps[endOffset] >= timeEnd;
+             endOffset--) {
+          // nothing needed, "for" is doing the work.
+        }
       }
 
       // Adjust: endOffset is now pointing at the last row to aggregate, but 
we want it
diff --git 
a/processing/src/main/java/org/apache/druid/segment/BitmapOffset.java 
b/processing/src/main/java/org/apache/druid/segment/BitmapOffset.java
index 94324fe2206..8363a2cde04 100644
--- a/processing/src/main/java/org/apache/druid/segment/BitmapOffset.java
+++ b/processing/src/main/java/org/apache/druid/segment/BitmapOffset.java
@@ -28,6 +28,7 @@ import 
org.apache.druid.query.monomorphicprocessing.RuntimeShapeInspector;
 import org.apache.druid.segment.data.Offset;
 import org.apache.druid.segment.data.ReadableOffset;
 import org.roaringbitmap.IntIterator;
+import org.roaringbitmap.PeekableIntIterator;
 
 import javax.annotation.Nullable;
 import java.util.Arrays;
@@ -130,7 +131,13 @@ public class BitmapOffset extends Offset
   private IntIterator iteratorForMark;
   private int valueForMark;
 
-  public static IntIterator getReverseBitmapOffsetIterator(ImmutableBitmap 
bitmapIndex)
+  /**
+   * Returns an iterator over the set bits of "bitmapIndex", from highest to 
lowest. The returned iterator's
+   * {@link PeekableIntIterator#advanceIfNeeded(int)} deviates from its 
contract in order to remain useful:
+   * it advances as long as the next value is larger than the given value, 
i.e., it is the reverse-order
+   * analog of the forward iterator's behavior.
+   */
+  public static PeekableIntIterator 
getReverseBitmapOffsetIterator(ImmutableBitmap bitmapIndex)
   {
     ImmutableBitmap roaringBitmap = bitmapIndex;
     if (!(bitmapIndex instanceof WrappedImmutableRoaringBitmap)) {
diff --git 
a/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorHolder.java
 
b/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorHolder.java
index db5de51a663..d4b79cc95d4 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorHolder.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/QueryableIndexCursorHolder.java
@@ -24,6 +24,7 @@ import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import org.apache.druid.collections.bitmap.BitmapFactory;
+import org.apache.druid.collections.bitmap.ImmutableBitmap;
 import org.apache.druid.java.util.common.io.Closer;
 import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.query.BaseQuery;
@@ -51,9 +52,12 @@ import org.apache.druid.segment.data.ReadableOffset;
 import org.apache.druid.segment.filter.AndFilter;
 import org.apache.druid.segment.historical.HistoricalCursor;
 import org.apache.druid.segment.vector.BitmapVectorOffset;
+import org.apache.druid.segment.vector.DescendingBitmapVectorOffset;
+import org.apache.druid.segment.vector.DescendingNoFilterVectorOffset;
 import org.apache.druid.segment.vector.FilteredVectorOffset;
 import org.apache.druid.segment.vector.NoFilterVectorOffset;
 import 
org.apache.druid.segment.vector.QueryableIndexVectorColumnSelectorFactory;
+import org.apache.druid.segment.vector.ReverseVectorColumnSelectorFactory;
 import org.apache.druid.segment.vector.VectorColumnSelectorFactory;
 import org.apache.druid.segment.vector.VectorCursor;
 import org.apache.druid.segment.vector.VectorOffset;
@@ -171,8 +175,9 @@ public class QueryableIndexCursorHolder implements 
CursorHolder
       }
     }
 
-    // vector cursors can't iterate backwards yet
-    return Cursors.getTimeOrdering(ordering) != Order.DESCENDING;
+    // Descending time order is handled by iterating the underlying offset 
back-to-front and reversing the decoded
+    // vectors via ReverseVectorColumnSelectorFactory; see asVectorCursor.
+    return true;
   }
 
   @Override
@@ -296,38 +301,65 @@ public class QueryableIndexCursorHolder implements 
CursorHolder
       endOffset = index.getNumRows();
     }
 
+    // For descending time order, the offset iterates the [startOffset, 
endOffset) range back-to-front (one batch at a
+    // time, ascending within each batch), and the column selector factory 
returned to the caller is wrapped in a
+    // ReverseVectorColumnSelectorFactory that flips each batch's decoded 
values. This keeps the low-level column
+    // readers seeing only ascending offsets.
+    final boolean descending = timeOrder == Order.DESCENDING;
+
     // filterBundle will only be null if the filter itself is null, otherwise 
check to see if the filter can use
     // an index
-    final VectorOffset baseOffset =
-        filterBundle == null || filterBundle.getIndex() == null
-        ? new NoFilterVectorOffset(vectorSize, startOffset, endOffset)
-        : new BitmapVectorOffset(vectorSize, 
filterBundle.getIndex().getBitmap(), startOffset, endOffset);
+    final VectorOffset baseOffset;
+    if (filterBundle == null || filterBundle.getIndex() == null) {
+      baseOffset =
+          descending
+          ? new DescendingNoFilterVectorOffset(vectorSize, startOffset, 
endOffset)
+          : new NoFilterVectorOffset(vectorSize, startOffset, endOffset);
+    } else {
+      final ImmutableBitmap bitmap = filterBundle.getIndex().getBitmap();
+      baseOffset =
+          descending
+          ? new DescendingBitmapVectorOffset(vectorSize, bitmap, startOffset, 
endOffset)
+          : new BitmapVectorOffset(vectorSize, bitmap, startOffset, endOffset);
+    }
 
-    // baseColumnSelectorFactory using baseOffset is the column selector for 
filtering.
+    // baseColumnSelectorFactory using baseOffset is the column selector for 
filtering. Filtering is order-independent,
+    // so it always reads ascending (unreversed) offsets.
     final VectorColumnSelectorFactory baseColumnSelectorFactory = 
makeVectorColumnSelectorFactoryForOffset(
         columnCache,
         baseOffset
     );
 
+    final VectorOffset cursorOffset;
+    final VectorColumnSelectorFactory cursorColumnSelectorFactory;
+
     // filterBundle will only be null if the filter itself is null, otherwise 
check to see if the filter needs to use
     // a value matcher
     if (filterBundle != null && filterBundle.getMatcherBundle() != null) {
       final VectorValueMatcher vectorValueMatcher = 
filterBundle.getMatcherBundle()
                                                                 
.vectorMatcher(baseColumnSelectorFactory, baseOffset);
-      final VectorOffset filteredOffset = FilteredVectorOffset.create(
+      cursorOffset = FilteredVectorOffset.create(
           baseOffset,
           vectorValueMatcher
       );
 
-      // Now create the cursor and column selector that will be returned to 
the caller.
-      final VectorColumnSelectorFactory filteredColumnSelectorFactory = 
makeVectorColumnSelectorFactoryForOffset(
+      // Now create the column selector that will be returned to the caller.
+      cursorColumnSelectorFactory = makeVectorColumnSelectorFactoryForOffset(
           columnCache,
-          filteredOffset
+          cursorOffset
       );
-      return new QueryableIndexVectorCursor(filteredColumnSelectorFactory, 
filteredOffset, vectorSize);
     } else {
-      return new QueryableIndexVectorCursor(baseColumnSelectorFactory, 
baseOffset, vectorSize);
+      cursorOffset = baseOffset;
+      cursorColumnSelectorFactory = baseColumnSelectorFactory;
     }
+
+    return new QueryableIndexVectorCursor(
+        descending
+        ? new ReverseVectorColumnSelectorFactory(cursorColumnSelectorFactory)
+        : cursorColumnSelectorFactory,
+        cursorOffset,
+        vectorSize
+    );
   }
 
   @Override
diff --git 
a/processing/src/main/java/org/apache/druid/segment/data/ColumnarDoubles.java 
b/processing/src/main/java/org/apache/druid/segment/data/ColumnarDoubles.java
index ef731c36b3e..d0764ba492f 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/data/ColumnarDoubles.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/data/ColumnarDoubles.java
@@ -202,10 +202,11 @@ public interface ColumnarDoubles extends Closeable
           ColumnarDoubles.this.get(doubleVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          if (offsets[offsets.length - 1] < offsetMark) {
+          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+          if (maxOffset < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = offsets[offsets.length - 1];
+          offsetMark = maxOffset;
           ColumnarDoubles.this.get(doubleVector, offsets, 
offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/data/ColumnarFloats.java 
b/processing/src/main/java/org/apache/druid/segment/data/ColumnarFloats.java
index ba9e99e5429..1276f3294e7 100644
--- a/processing/src/main/java/org/apache/druid/segment/data/ColumnarFloats.java
+++ b/processing/src/main/java/org/apache/druid/segment/data/ColumnarFloats.java
@@ -197,10 +197,11 @@ public interface ColumnarFloats extends Closeable
           ColumnarFloats.this.get(floatVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          if (offsets[offsets.length - 1] < offsetMark) {
+          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+          if (maxOffset < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = offsets[offsets.length - 1];
+          offsetMark = maxOffset;
           ColumnarFloats.this.get(floatVector, offsets, 
offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/data/ColumnarLongs.java 
b/processing/src/main/java/org/apache/druid/segment/data/ColumnarLongs.java
index bb029b3c645..0c672195e8b 100644
--- a/processing/src/main/java/org/apache/druid/segment/data/ColumnarLongs.java
+++ b/processing/src/main/java/org/apache/druid/segment/data/ColumnarLongs.java
@@ -208,10 +208,11 @@ public interface ColumnarLongs extends Closeable
           ColumnarLongs.this.get(longVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          if (offsets[offsets.length - 1] < offsetMark) {
+          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+          if (maxOffset < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = offsets[offsets.length - 1];
+          offsetMark = maxOffset;
           ColumnarLongs.this.get(longVector, offsets, 
offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/nested/NestedFieldDictionaryEncodedColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/nested/NestedFieldDictionaryEncodedColumn.java
index 261da5ddfc7..43a2080d7a6 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/nested/NestedFieldDictionaryEncodedColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/nested/NestedFieldDictionaryEncodedColumn.java
@@ -912,10 +912,11 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
               longsColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
             } else {
               final int[] offsets = offset.getOffsets();
-              if (offsets[offsets.length - 1] < offsetMark) {
+              final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+              if (maxOffset < offsetMark) {
                 nullIterator = nullBitmap.peekableIterator();
               }
-              offsetMark = offsets[offsets.length - 1];
+              offsetMark = maxOffset;
               longsColumn.get(valueVector, offsets, 
offset.getCurrentVectorSize());
             }
 
@@ -967,10 +968,11 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
               doublesColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
             } else {
               final int[] offsets = offset.getOffsets();
-              if (offsets[offsets.length - 1] < offsetMark) {
+              final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+              if (maxOffset < offsetMark) {
                 nullIterator = nullBitmap.peekableIterator();
               }
-              offsetMark = offsets[offsets.length - 1];
+              offsetMark = maxOffset;
               doublesColumn.get(valueVector, offsets, 
offset.getCurrentVectorSize());
             }
 
@@ -1026,10 +1028,11 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
             column.get(idVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
           } else {
             final int[] offsets = offset.getOffsets();
-            if (offsets[offsets.length - 1] < offsetMark) {
+            final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+            if (maxOffset < offsetMark) {
               nullIterator = nullBitmap.peekableIterator();
             }
-            offsetMark = offsets[offsets.length - 1];
+            offsetMark = maxOffset;
             column.get(idVector, offsets, offset.getCurrentVectorSize());
           }
           for (int i = 0; i < offset.getCurrentVectorSize(); i++) {
diff --git 
a/processing/src/main/java/org/apache/druid/segment/nested/ScalarDoubleColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/nested/ScalarDoubleColumn.java
index c6c7137e0cf..4252cf56342 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/nested/ScalarDoubleColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/nested/ScalarDoubleColumn.java
@@ -170,10 +170,11 @@ public class ScalarDoubleColumn implements 
NestedCommonFormatColumn
           valueColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          if (offsets[offsets.length - 1] < offsetMark) {
+          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+          if (maxOffset < offsetMark) {
             nullIterator = nullValueIndex.peekableIterator();
           }
-          offsetMark = offsets[offsets.length - 1];
+          offsetMark = maxOffset;
           valueColumn.get(valueVector, offsets, offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/nested/ScalarLongColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/nested/ScalarLongColumn.java
index 3d7ccd458be..9fa57b7c731 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/nested/ScalarLongColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/nested/ScalarLongColumn.java
@@ -171,10 +171,11 @@ public class ScalarLongColumn implements 
NestedCommonFormatColumn
           valueColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          if (offsets[offsets.length - 1] < offsetMark) {
+          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+          if (maxOffset < offsetMark) {
             nullIterator = nullValueIndex.peekableIterator();
           }
-          offsetMark = offsets[offsets.length - 1];
+          offsetMark = maxOffset;
           valueColumn.get(valueVector, offsets, offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/nested/VariantColumn.java 
b/processing/src/main/java/org/apache/druid/segment/nested/VariantColumn.java
index 05470edae96..1bbf3ffe26e 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/nested/VariantColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/nested/VariantColumn.java
@@ -829,10 +829,11 @@ public class VariantColumn<TStringDictionary extends 
Indexed<ByteBuffer>>
             encodedValueColumn.get(idVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
           } else {
             final int[] offsets = offset.getOffsets();
-            if (offsets[offsets.length - 1] < offsetMark) {
+            final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
+            if (maxOffset < offsetMark) {
               nullIterator = nullValueBitmap.peekableIterator();
             }
-            offsetMark = offsets[offsets.length - 1];
+            offsetMark = maxOffset;
             encodedValueColumn.get(idVector, offsets, 
offset.getCurrentVectorSize());
           }
           for (int i = 0; i < offset.getCurrentVectorSize(); i++) {
diff --git 
a/processing/src/main/java/org/apache/druid/segment/vector/DescendingBitmapVectorOffset.java
 
b/processing/src/main/java/org/apache/druid/segment/vector/DescendingBitmapVectorOffset.java
new file mode 100644
index 00000000000..fca49fc8d15
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/vector/DescendingBitmapVectorOffset.java
@@ -0,0 +1,154 @@
+/*
+ * 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.segment.vector;
+
+import com.google.common.base.Preconditions;
+import org.apache.druid.collections.bitmap.ImmutableBitmap;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.BitmapOffset;
+import org.roaringbitmap.PeekableIntIterator;
+
+import javax.annotation.Nullable;
+
+/**
+ * Like {@link BitmapVectorOffset}, but in descending order.
+ *
+ * <p>As required by {@link ReadableVectorOffset#getOffsets()}, the offsets 
within each batch are in internally
+ * ascending order.
+ *
+ * @see DescendingNoFilterVectorOffset the no-filter version
+ */
+public class DescendingBitmapVectorOffset implements VectorOffset
+{
+  private final ImmutableBitmap bitmap;
+  private final int[] offsets;
+  private final int startOffset;
+  private final int endOffset;
+
+  // Null when [startOffset, endOffset) is empty, in which case this offset is 
immediately done.
+  @Nullable
+  private PeekableIntIterator iterator;
+  private int currentVectorSize;
+  private boolean isContiguous;
+
+  public DescendingBitmapVectorOffset(
+      final int vectorSize,
+      final ImmutableBitmap bitmap,
+      final int startOffset,
+      final int endOffset
+  )
+  {
+    this.bitmap = bitmap;
+    this.offsets = new int[vectorSize];
+    this.startOffset = startOffset;
+    this.endOffset = endOffset;
+    reset();
+  }
+
+  @Override
+  public int getId()
+  {
+    Preconditions.checkState(currentVectorSize > 0, "currentVectorSize > 0");
+    return offsets[0];
+  }
+
+  @Override
+  public void advance()
+  {
+    // The iterator yields offsets from highest to lowest, so fill the array 
back-to-front (internally-ascending order).
+    int i = offsets.length;
+    while (i > 0 && iterator != null && iterator.hasNext() && 
iterator.peekNext() >= startOffset) {
+      offsets[--i] = iterator.next();
+    }
+
+    currentVectorSize = offsets.length - i;
+
+    if (i > 0 && currentVectorSize > 0) {
+      // Partial batch: move it to the front of the array.
+      System.arraycopy(offsets, i, offsets, 0, currentVectorSize);
+    }
+
+    isContiguous = false;
+    if (currentVectorSize > 1) {
+      final int hiPos = currentVectorSize - 1;
+      isContiguous = offsets[hiPos] - offsets[0] == hiPos;
+    }
+  }
+
+  @Override
+  public boolean isDone()
+  {
+    return currentVectorSize == 0;
+  }
+
+  @Override
+  public boolean isContiguous()
+  {
+    return isContiguous;
+  }
+
+  @Override
+  public int getMaxVectorSize()
+  {
+    return offsets.length;
+  }
+
+  @Override
+  public int getCurrentVectorSize()
+  {
+    return currentVectorSize;
+  }
+
+  @Override
+  public int getStartOffset()
+  {
+    if (isContiguous) {
+      return offsets[0];
+    } else {
+      throw DruidException.defensive("Cannot call getStartOffset() on a 
non-contiguous offset");
+    }
+  }
+
+  @Override
+  public int[] getOffsets()
+  {
+    if (!isContiguous) {
+      return offsets;
+    } else {
+      throw DruidException.defensive("Cannot call getOffsets() on a contiguous 
offset");
+    }
+  }
+
+  @Override
+  public void reset()
+  {
+    currentVectorSize = 0;
+    isContiguous = false;
+
+    if (startOffset < endOffset) {
+      iterator = BitmapOffset.getReverseBitmapOffsetIterator(bitmap);
+      iterator.advanceIfNeeded(endOffset - 1);
+      advance();
+    } else {
+      // Empty range.
+      iterator = null;
+    }
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/vector/DescendingNoFilterVectorOffset.java
 
b/processing/src/main/java/org/apache/druid/segment/vector/DescendingNoFilterVectorOffset.java
new file mode 100644
index 00000000000..da46d11662a
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/vector/DescendingNoFilterVectorOffset.java
@@ -0,0 +1,107 @@
+/*
+ * 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.segment.vector;
+
+/**
+ * Like {@link NoFilterVectorOffset}, but in descending order.
+ *
+ * <p>As required by {@link ReadableVectorOffset#getOffsets()}, the offsets 
within each batch are in internally
+ * ascending order.
+ */
+public class DescendingNoFilterVectorOffset implements VectorOffset
+{
+  private final int maxVectorSize;
+  private final int start;
+  private final int end;
+
+  private int currentStart;
+  private int currentEnd;
+
+  public DescendingNoFilterVectorOffset(final int maxVectorSize, final int 
start, final int end)
+  {
+    this.maxVectorSize = maxVectorSize;
+    this.start = start;
+    this.end = end;
+    reset();
+  }
+
+  @Override
+  public int getId()
+  {
+    return currentStart;
+  }
+
+  @Override
+  public void advance()
+  {
+    currentEnd = currentStart;
+    currentStart = computeCurrentStart();
+  }
+
+  @Override
+  public boolean isDone()
+  {
+    return currentEnd <= start;
+  }
+
+  @Override
+  public boolean isContiguous()
+  {
+    return true;
+  }
+
+  @Override
+  public int getMaxVectorSize()
+  {
+    return maxVectorSize;
+  }
+
+  @Override
+  public int getCurrentVectorSize()
+  {
+    return currentEnd - currentStart;
+  }
+
+  @Override
+  public int getStartOffset()
+  {
+    return currentStart;
+  }
+
+  @Override
+  public int[] getOffsets()
+  {
+    throw new UnsupportedOperationException("no filter");
+  }
+
+  @Override
+  public void reset()
+  {
+    currentEnd = end;
+    currentStart = computeCurrentStart();
+  }
+
+  private int computeCurrentStart()
+  {
+    // Align batch boundaries down to a multiple of maxVectorSize, counting 
from the beginning of the segment. When
+    // maxVectorSize divides the compressed block size, this prevents batch 
boundaries from crossing block boundaries.
+    return Math.max(start, (currentEnd - 1) / maxVectorSize * maxVectorSize);
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/vector/ReadableVectorOffset.java
 
b/processing/src/main/java/org/apache/druid/segment/vector/ReadableVectorOffset.java
index 35cb0059946..9076e099eca 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/vector/ReadableVectorOffset.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/vector/ReadableVectorOffset.java
@@ -47,9 +47,13 @@ public interface ReadableVectorOffset extends 
ReadableVectorInspector
 
   /**
    * If "isContiguous" is false, this method returns a batch of offsets. The 
array may be longer than the number of
-   * valid offsets, so callers need to check "getCurrentVectorSize" too.
+   * valid offsets, so callers need to check "getCurrentVectorSize" too. 
Entries at or past "getCurrentVectorSize" are
+   * not meaningful and must not be read.
    *
-   * Throws an exception if "isContiguous" is true.
+   * <p>Offsets in this array are always ascending, even if the cursor is 
descending
+   * (see {@link ReverseVectorColumnSelectorFactory}).
+   *
+   * <p>Throws an exception if "isContiguous" is true.
    */
   int[] getOffsets();
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/vector/ReverseVectorColumnSelectorFactory.java
 
b/processing/src/main/java/org/apache/druid/segment/vector/ReverseVectorColumnSelectorFactory.java
new file mode 100644
index 00000000000..086fe44afdb
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/vector/ReverseVectorColumnSelectorFactory.java
@@ -0,0 +1,434 @@
+/*
+ * 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.segment.vector;
+
+import org.apache.druid.error.NotYetImplemented;
+import org.apache.druid.query.dimension.DimensionSpec;
+import org.apache.druid.query.groupby.DeferExpressionDimensions;
+import 
org.apache.druid.query.groupby.epinephelinae.vector.GroupByVectorColumnSelector;
+import org.apache.druid.segment.DimensionDictionarySelector;
+import org.apache.druid.segment.IdLookup;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.data.IndexedInts;
+
+import javax.annotation.Nullable;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Wraps a {@link VectorColumnSelectorFactory} whose underlying offset 
iterates batches in descending order (see
+ * {@link DescendingNoFilterVectorOffset} and {@link 
DescendingBitmapVectorOffset}). The underlying offset generates
+ * batches in descending order (from the end of the segment to the start), but 
each batch is internally in ascending
+ * order. This class reverses each batch internally, to provide an overall 
descending order.
+ *
+ * <p>Reversing is done here (on already-decoded vectors) so that low-level 
column readers only ever need to return
+ * batches in internally-ascending order. This allows them to be simpler, as 
they do not need to have handling for
+ * both ascending and descending order.
+ */
+public class ReverseVectorColumnSelectorFactory implements 
VectorColumnSelectorFactory
+{
+  private final VectorColumnSelectorFactory delegate;
+  private final ReadableVectorInspector inspector;
+
+  private final Map<DimensionSpec, SingleValueDimensionVectorSelector> 
singleValueDimensionSelectorCache = new HashMap<>();
+  private final Map<DimensionSpec, MultiValueDimensionVectorSelector> 
multiValueDimensionSelectorCache = new HashMap<>();
+  private final Map<String, VectorValueSelector> valueSelectorCache = new 
HashMap<>();
+  private final Map<String, VectorObjectSelector> objectSelectorCache = new 
HashMap<>();
+
+  public ReverseVectorColumnSelectorFactory(final VectorColumnSelectorFactory 
delegate)
+  {
+    this.delegate = delegate;
+    this.inspector = delegate.getReadableVectorInspector();
+  }
+
+  @Override
+  public ReadableVectorInspector getReadableVectorInspector()
+  {
+    return inspector;
+  }
+
+  @Override
+  public SingleValueDimensionVectorSelector 
makeSingleValueDimensionSelector(final DimensionSpec dimensionSpec)
+  {
+    return singleValueDimensionSelectorCache.computeIfAbsent(
+        dimensionSpec,
+        spec -> new ReverseSingleValueDimensionVectorSelector(
+            delegate.makeSingleValueDimensionSelector(spec),
+            inspector
+        )
+    );
+  }
+
+  @Override
+  public MultiValueDimensionVectorSelector 
makeMultiValueDimensionSelector(final DimensionSpec dimensionSpec)
+  {
+    return multiValueDimensionSelectorCache.computeIfAbsent(
+        dimensionSpec,
+        spec -> new 
ReverseMultiValueDimensionVectorSelector(delegate.makeMultiValueDimensionSelector(spec),
 inspector)
+    );
+  }
+
+  @Override
+  public VectorValueSelector makeValueSelector(final String column)
+  {
+    return valueSelectorCache.computeIfAbsent(
+        column,
+        c -> new ReverseVectorValueSelector(delegate.makeValueSelector(c), 
inspector)
+    );
+  }
+
+  @Override
+  public VectorObjectSelector makeObjectSelector(final String column)
+  {
+    return objectSelectorCache.computeIfAbsent(
+        column,
+        c -> new ReverseVectorObjectSelector(delegate.makeObjectSelector(c), 
inspector)
+    );
+  }
+
+  @Nullable
+  @Override
+  public ColumnCapabilities getColumnCapabilities(final String column)
+  {
+    return delegate.getColumnCapabilities(column);
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final long[] src, final long[] dst, final 
int n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final float[] src, final float[] dst, final 
int n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final double[] src, final double[] dst, 
final int n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final boolean[] src, final boolean[] dst, 
final int n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final int[] src, final int[] dst, final int 
n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Reverse n items of src into dst.
+   */
+  private static void reverseInto(final Object[] src, final Object[] dst, 
final int n)
+  {
+    for (int i = 0; i < n; i++) {
+      dst[i] = src[n - 1 - i];
+    }
+  }
+
+  /**
+   * Base class for the reversing selectors. Each one reverses the valid 
entries of a freshly-decoded vector into a
+   * scratch array, caching by vector id so that a given batch is only 
reversed once.
+   */
+  private abstract static class ReverseVectorSelector implements 
VectorSizeInspector
+  {
+    final ReadableVectorInspector inspector;
+
+    ReverseVectorSelector(final ReadableVectorInspector inspector)
+    {
+      this.inspector = inspector;
+    }
+
+    @Override
+    public int getMaxVectorSize()
+    {
+      return inspector.getMaxVectorSize();
+    }
+
+    @Override
+    public int getCurrentVectorSize()
+    {
+      return inspector.getCurrentVectorSize();
+    }
+  }
+
+  /**
+   * Base class for the reversing dimension selectors, which delegate all the 
dictionary lookups.
+   */
+  private abstract static class ReverseDimensionVectorSelector extends 
ReverseVectorSelector
+      implements DimensionDictionarySelector
+  {
+    private final DimensionDictionarySelector delegate;
+
+    ReverseDimensionVectorSelector(final DimensionDictionarySelector delegate, 
final ReadableVectorInspector inspector)
+    {
+      super(inspector);
+      this.delegate = delegate;
+    }
+
+    @Override
+    public int getValueCardinality()
+    {
+      return delegate.getValueCardinality();
+    }
+
+    @Nullable
+    @Override
+    public String lookupName(final int id)
+    {
+      return delegate.lookupName(id);
+    }
+
+    @Nullable
+    @Override
+    public ByteBuffer lookupNameUtf8(final int id)
+    {
+      return delegate.lookupNameUtf8(id);
+    }
+
+    @Override
+    public boolean supportsLookupNameUtf8()
+    {
+      return delegate.supportsLookupNameUtf8();
+    }
+
+    @Override
+    public boolean nameLookupPossibleInAdvance()
+    {
+      return delegate.nameLookupPossibleInAdvance();
+    }
+
+    @Nullable
+    @Override
+    public IdLookup idLookup()
+    {
+      return delegate.idLookup();
+    }
+  }
+
+  private static class ReverseVectorValueSelector extends 
ReverseVectorSelector implements VectorValueSelector
+  {
+    private final VectorValueSelector delegate;
+
+    @Nullable
+    private long[] longs;
+    @Nullable
+    private float[] floats;
+    @Nullable
+    private double[] doubles;
+    @Nullable
+    private boolean[] nulls;
+
+    private int longId = ReadableVectorInspector.NULL_ID;
+    private int floatId = ReadableVectorInspector.NULL_ID;
+    private int doubleId = ReadableVectorInspector.NULL_ID;
+    private int nullId = ReadableVectorInspector.NULL_ID;
+
+    // Null when the current batch has no nulls at all, in which case 
getNullVector returns null.
+    @Nullable
+    private boolean[] currentNulls;
+
+    ReverseVectorValueSelector(final VectorValueSelector delegate, final 
ReadableVectorInspector inspector)
+    {
+      super(inspector);
+      this.delegate = delegate;
+    }
+
+    @Override
+    public long[] getLongVector()
+    {
+      if (longs == null) {
+        longs = new long[inspector.getMaxVectorSize()];
+      }
+      if (longId != inspector.getId()) {
+        reverseInto(delegate.getLongVector(), longs, 
inspector.getCurrentVectorSize());
+        longId = inspector.getId();
+      }
+      return longs;
+    }
+
+    @Override
+    public float[] getFloatVector()
+    {
+      if (floats == null) {
+        floats = new float[inspector.getMaxVectorSize()];
+      }
+      if (floatId != inspector.getId()) {
+        reverseInto(delegate.getFloatVector(), floats, 
inspector.getCurrentVectorSize());
+        floatId = inspector.getId();
+      }
+      return floats;
+    }
+
+    @Override
+    public double[] getDoubleVector()
+    {
+      if (doubles == null) {
+        doubles = new double[inspector.getMaxVectorSize()];
+      }
+      if (doubleId != inspector.getId()) {
+        reverseInto(delegate.getDoubleVector(), doubles, 
inspector.getCurrentVectorSize());
+        doubleId = inspector.getId();
+      }
+      return doubles;
+    }
+
+    @Nullable
+    @Override
+    public boolean[] getNullVector()
+    {
+      if (nullId != inspector.getId()) {
+        final boolean[] src = delegate.getNullVector();
+        if (src == null) {
+          currentNulls = null;
+        } else {
+          if (nulls == null) {
+            nulls = new boolean[inspector.getMaxVectorSize()];
+          }
+          reverseInto(src, nulls, inspector.getCurrentVectorSize());
+          currentNulls = nulls;
+        }
+        nullId = inspector.getId();
+      }
+      return currentNulls;
+    }
+  }
+
+  @Override
+  public GroupByVectorColumnSelector makeGroupByVectorColumnSelector(
+      final String column,
+      final DeferExpressionDimensions deferExpressionDimensions
+  )
+  {
+    // groupBy does not use descending cursors, so this method is not needed.
+    throw NotYetImplemented.ex(null, "makeGroupByVectorColumnSelector is not 
needed for descending cursors");
+  }
+
+  private static class ReverseVectorObjectSelector extends 
ReverseVectorSelector implements VectorObjectSelector
+  {
+    private final VectorObjectSelector delegate;
+    private final Object[] objects;
+
+    private int id = ReadableVectorInspector.NULL_ID;
+
+    ReverseVectorObjectSelector(final VectorObjectSelector delegate, final 
ReadableVectorInspector inspector)
+    {
+      super(inspector);
+      this.delegate = delegate;
+      this.objects = new Object[inspector.getMaxVectorSize()];
+    }
+
+    @Override
+    public Object[] getObjectVector()
+    {
+      if (id != inspector.getId()) {
+        reverseInto(delegate.getObjectVector(), objects, 
inspector.getCurrentVectorSize());
+        id = inspector.getId();
+      }
+      return objects;
+    }
+  }
+
+  private static class ReverseSingleValueDimensionVectorSelector extends 
ReverseDimensionVectorSelector
+      implements SingleValueDimensionVectorSelector
+  {
+    private final SingleValueDimensionVectorSelector delegate;
+    private final int[] rows;
+
+    private int id = ReadableVectorInspector.NULL_ID;
+
+    ReverseSingleValueDimensionVectorSelector(
+        final SingleValueDimensionVectorSelector delegate,
+        final ReadableVectorInspector inspector
+    )
+    {
+      super(delegate, inspector);
+      this.delegate = delegate;
+      this.rows = new int[inspector.getMaxVectorSize()];
+    }
+
+    @Override
+    public int[] getRowVector()
+    {
+      if (id != inspector.getId()) {
+        reverseInto(delegate.getRowVector(), rows, 
inspector.getCurrentVectorSize());
+        id = inspector.getId();
+      }
+      return rows;
+    }
+  }
+
+  private static class ReverseMultiValueDimensionVectorSelector extends 
ReverseDimensionVectorSelector
+      implements MultiValueDimensionVectorSelector
+  {
+    private final MultiValueDimensionVectorSelector delegate;
+    private final IndexedInts[] rows;
+
+    private int id = ReadableVectorInspector.NULL_ID;
+
+    ReverseMultiValueDimensionVectorSelector(
+        final MultiValueDimensionVectorSelector delegate,
+        final ReadableVectorInspector inspector
+    )
+    {
+      super(delegate, inspector);
+      this.delegate = delegate;
+      this.rows = new IndexedInts[inspector.getMaxVectorSize()];
+    }
+
+    @Override
+    public IndexedInts[] getRowVector()
+    {
+      if (id != inspector.getId()) {
+        reverseInto(delegate.getRowVector(), rows, 
inspector.getCurrentVectorSize());
+        id = inspector.getId();
+      }
+      return rows;
+    }
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/query/QueryRunnerTestHelper.java 
b/processing/src/test/java/org/apache/druid/query/QueryRunnerTestHelper.java
index 2f17b310b67..a204bdcc2f7 100644
--- a/processing/src/test/java/org/apache/druid/query/QueryRunnerTestHelper.java
+++ b/processing/src/test/java/org/apache/druid/query/QueryRunnerTestHelper.java
@@ -28,10 +28,6 @@ import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.java.util.common.granularity.Granularity;
-import org.apache.druid.java.util.common.guava.MergeSequence;
-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.io.Closer;
 import org.apache.druid.js.JavaScriptConfig;
 import org.apache.druid.math.expr.ExprMacroTable;
 import org.apache.druid.query.aggregation.AggregatorFactory;
@@ -58,21 +54,15 @@ import org.apache.druid.query.planning.ExecutionVertex;
 import org.apache.druid.query.policy.NoopPolicyEnforcer;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
 import org.apache.druid.query.spec.QuerySegmentSpec;
-import org.apache.druid.query.spec.SpecificSegmentSpec;
 import org.apache.druid.query.timeseries.TimeseriesQueryEngine;
 import org.apache.druid.query.timeseries.TimeseriesQueryQueryToolChest;
 import org.apache.druid.query.timeseries.TimeseriesQueryRunnerFactory;
 import org.apache.druid.segment.IncrementalIndexSegment;
 import org.apache.druid.segment.QueryableIndexSegment;
-import org.apache.druid.segment.ReferenceCountedSegmentProvider;
 import org.apache.druid.segment.Segment;
 import org.apache.druid.segment.TestIndex;
 import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
-import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
-import org.apache.druid.timeline.TimelineObjectHolder;
-import org.apache.druid.timeline.VersionedIntervalTimeline;
-import org.apache.druid.utils.CloseableUtils;
 import org.joda.time.DateTime;
 import org.joda.time.Interval;
 
@@ -567,61 +557,6 @@ public class QueryRunnerTestHelper
     return makeQueryRunner(factory, segmentReference.orElseThrow(), 
runnerName);
   }
 
-  @SuppressWarnings({"rawtypes", "unchecked"})
-  public static <T> QueryRunner<T> makeFilteringQueryRunner(
-      final VersionedIntervalTimeline<String, DataSegment> timeline,
-      final Map<DataSegment, ReferenceCountedSegmentProvider> 
referenceProviders,
-      final QueryRunnerFactory<T, Query<T>> factory
-  )
-  {
-    final QueryToolChest<T, Query<T>> toolChest = factory.getToolchest();
-    return FluentQueryRunner
-        .create(
-            (queryPlus, responseContext) -> {
-              Query<T> query = queryPlus.getQuery();
-              List<TimelineObjectHolder> segments = new ArrayList<>();
-              for (Interval interval : query.getIntervals()) {
-                segments.addAll(timeline.lookup(interval));
-              }
-              List<Sequence<T>> sequences = new ArrayList<>();
-              final Closer closer = Closer.create();
-              try {
-                for (TimelineObjectHolder<String, DataSegment> holder : 
toolChest.filterSegments(
-                    query,
-                    segments
-                )) {
-                  final SegmentDescriptor descriptor = new SegmentDescriptor(
-                      holder.getInterval(),
-                      holder.getVersion(),
-                      0
-                  );
-                  final QueryPlus queryPlusRunning = queryPlus.withQuery(
-                      queryPlus.getQuery().withQuerySegmentSpec(new 
SpecificSegmentSpec(descriptor))
-                  );
-                  final ReferenceCountedSegmentProvider referenceProvider = 
referenceProviders.get(
-                      holder.getObject().getChunk(0).getObject()
-                  );
-                  final QueryRunner<?> runner = factory.createRunner(
-                      
closer.register(referenceProvider.acquireReference().orElseThrow())
-                  );
-                  sequences.add(runner.run(queryPlusRunning, responseContext));
-                }
-                return Sequences.withBaggage(
-                    new MergeSequence<>(query.getResultOrdering(), 
Sequences.simple(sequences)),
-                    closer
-                );
-              }
-              catch (Throwable t) {
-                throw CloseableUtils.closeAndWrapInCatch(t, closer);
-              }
-            },
-            toolChest
-        )
-        .applyPreMergeDecoration()
-        .mergeResults(true)
-        .applyPostMergeDecoration();
-  }
-
   public static Map<String, Object> of(Object... keyvalues)
   {
     ImmutableMap.Builder<String, Object> builder = ImmutableMap.builder();
diff --git 
a/processing/src/test/java/org/apache/druid/query/timeboundary/TimeBoundaryQueryRunnerTest.java
 
b/processing/src/test/java/org/apache/druid/query/timeboundary/TimeBoundaryQueryRunnerTest.java
index 755b325393e..9e9116a2cd3 100644
--- 
a/processing/src/test/java/org/apache/druid/query/timeboundary/TimeBoundaryQueryRunnerTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/timeboundary/TimeBoundaryQueryRunnerTest.java
@@ -22,43 +22,31 @@ package org.apache.druid.query.timeboundary;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.common.io.CharSource;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.UOE;
-import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.java.util.common.guava.Sequences;
 import org.apache.druid.query.Druids;
 import org.apache.druid.query.InlineDataSource;
+import org.apache.druid.query.QueryContexts;
 import org.apache.druid.query.QueryPlus;
 import org.apache.druid.query.QueryRunner;
-import org.apache.druid.query.QueryRunnerFactory;
 import org.apache.druid.query.QueryRunnerTestHelper;
 import org.apache.druid.query.Result;
 import org.apache.druid.query.TableDataSource;
 import org.apache.druid.query.TestQueryRunner;
 import org.apache.druid.query.context.ConcurrentResponseContext;
 import org.apache.druid.query.context.ResponseContext;
-import org.apache.druid.query.ordering.StringComparators;
+import org.apache.druid.query.filter.DimFilter;
+import org.apache.druid.query.filter.RangeFilter;
+import org.apache.druid.query.filter.SelectorDimFilter;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
-import org.apache.druid.segment.IncrementalIndexSegment;
-import org.apache.druid.segment.ReferenceCountedSegmentProvider;
+import org.apache.druid.segment.QueryableIndex;
 import org.apache.druid.segment.RowBasedSegment;
-import org.apache.druid.segment.Segment;
-import org.apache.druid.segment.TestHelper;
-import org.apache.druid.segment.TestIndex;
+import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
-import org.apache.druid.segment.incremental.IncrementalIndex;
-import org.apache.druid.segment.incremental.IncrementalIndexSchema;
-import org.apache.druid.segment.incremental.OnheapIncrementalIndex;
 import org.apache.druid.testing.InitializedNullHandlingTest;
-import org.apache.druid.timeline.DataSegment;
-import org.apache.druid.timeline.SegmentId;
-import org.apache.druid.timeline.VersionedIntervalTimeline;
-import org.apache.druid.timeline.partition.NoneShardSpec;
-import org.apache.druid.timeline.partition.NumberedShardSpec;
-import org.apache.druid.timeline.partition.SingleElementPartitionChunk;
 import org.joda.time.DateTime;
 import org.joda.time.DateTimeZone;
 import org.joda.time.Interval;
@@ -67,11 +55,12 @@ 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;
 import java.util.List;
-import java.util.Map;
 
 /**
  *
@@ -91,11 +80,6 @@ public class TimeBoundaryQueryRunnerTest extends 
InitializedNullHandlingTest
   }
 
   private final TestQueryRunner<Result<TimeBoundaryResultValue>> runner;
-  private static final QueryRunnerFactory FACTORY = new 
TimeBoundaryQueryRunnerFactory(
-      QueryRunnerTestHelper.NOOP_QUERYWATCHER
-  );
-  private static Segment segment0;
-  private static Segment segment1;
 
   public TimeBoundaryQueryRunnerTest(
       TestQueryRunner<Result<TimeBoundaryResultValue>> runner
@@ -104,150 +88,49 @@ public class TimeBoundaryQueryRunnerTest extends 
InitializedNullHandlingTest
     this.runner = runner;
   }
 
-  // Adapted from MultiSegmentSelectQueryTest, with modifications to make 
filtering meaningful
-  public static final String[] V_0112 = {
-      
"2011-01-12T01:00:00.000Z\tspot\tbusiness\t1100\t11000.0\t110000\tpreferred\tbpreferred\t100.000000",
-      
"2011-01-12T02:00:00.000Z\tspot\tentertainment\t1200\t12000.0\t120000\tpreferred\tepreferred\t100.000000",
-      
"2011-01-13T00:00:00.000Z\tspot\tautomotive\t1000\t10000.0\t100000\tpreferred\tapreferred\t100.000000",
-      
"2011-01-13T01:00:00.000Z\tspot\tbusiness\t1100\t11000.0\t110000\tpreferred\tbpreferred\t100.000000"
-  };
-  public static final String[] V_0113 = {
-      
"2011-01-14T00:00:00.000Z\tspot\tautomotive\t1000\t10000.0\t100000\tpreferred\tapreferred\t94.874713",
-      
"2011-01-14T02:00:00.000Z\tspot\tentertainment\t1200\t12000.0\t120000\tpreferred\tepreferred\t110.087299",
-      
"2011-01-15T00:00:00.000Z\tspot\tautomotive\t1000\t10000.0\t100000\tpreferred\tapreferred\t94.874713",
-      
"2011-01-15T01:00:00.000Z\tspot\tbusiness\t1100\t11000.0\t110000\tpreferred\tbpreferred\t103.629399",
-      
"2011-01-16T00:00:00.000Z\tspot\tautomotive\t1000\t10000.0\t100000\tpreferred\tapreferred\t94.874713",
-      
"2011-01-16T01:00:00.000Z\tspot\tbusiness\t1100\t11000.0\t110000\tpreferred\tbpreferred\t103.629399",
-      
"2011-01-16T02:00:00.000Z\tspot\tentertainment\t1200\t12000.0\t120000\tpreferred\tepreferred\t110.087299",
-      
"2011-01-17T01:00:00.000Z\tspot\tbusiness\t1100\t11000.0\t110000\tpreferred\tbpreferred\t103.629399",
-      
"2011-01-17T02:00:00.000Z\tspot\tentertainment\t1200\t12000.0\t120000\tpreferred\tepreferred\t110.087299"
-  };
-
-  private static IncrementalIndex newIndex(String minTimeStamp)
-  {
-    return newIndex(minTimeStamp, 10000);
-  }
-
-  private static IncrementalIndex newIndex(String minTimeStamp, int 
maxRowCount)
-  {
-    final IncrementalIndexSchema schema = new IncrementalIndexSchema.Builder()
-        .withMinTimestamp(DateTimes.of(minTimeStamp).getMillis())
-        .withQueryGranularity(Granularities.HOUR)
-        .withMetrics(TestIndex.METRIC_AGGS)
-        .build();
-    return new OnheapIncrementalIndex.Builder()
-        .setIndexSchema(schema)
-        .setMaxRowCount(maxRowCount)
-        .build();
-  }
-
-  private static SegmentId makeIdentifier(IncrementalIndex index, String 
version)
-  {
-    return makeIdentifier(index.getInterval(), version);
-  }
-
-  private static SegmentId makeIdentifier(Interval interval, String version)
-  {
-    return SegmentId.of(QueryRunnerTestHelper.DATA_SOURCE, interval, version, 
NoneShardSpec.instance());
-  }
-
-  private QueryRunner getCustomRunner() throws IOException
+  @Test
+  public void testFilteredTimeBoundaryQuery()
   {
-    CharSource v_0112 = CharSource.wrap(StringUtils.join(V_0112, "\n"));
-    CharSource v_0113 = CharSource.wrap(StringUtils.join(V_0113, "\n"));
-
-    IncrementalIndex index0 = 
TestIndex.loadIncrementalIndexFromTsvCharSource(newIndex("2011-01-12T00:00:00.000Z"),
 v_0112);
-    IncrementalIndex index1 = 
TestIndex.loadIncrementalIndexFromTsvCharSource(newIndex("2011-01-14T00:00:00.000Z"),
 v_0113);
-
-    segment0 = new IncrementalIndexSegment(index0, makeIdentifier(index0, 
"v1"));
-    segment1 = new IncrementalIndexSegment(index1, makeIdentifier(index1, 
"v1"));
-    final DataSegment dataSegment0 = TestHelper.toSimpleDataSegment(segment0, 
new NumberedShardSpec(0, 1));
-    final DataSegment dataSegment1 = TestHelper.toSimpleDataSegment(segment1, 
new NumberedShardSpec(0, 1));
-    Map<DataSegment, ReferenceCountedSegmentProvider> referenceProviders = 
Map.of(
-        dataSegment0, ReferenceCountedSegmentProvider.of(segment0),
-        dataSegment1, ReferenceCountedSegmentProvider.of(segment1)
-    );
-
-    VersionedIntervalTimeline<String, DataSegment> timeline = new 
VersionedIntervalTimeline<>(
-        StringComparators.LEXICOGRAPHIC);
-    timeline.add(
-        index0.getInterval(),
-        "v1",
-        new SingleElementPartitionChunk<>(dataSegment0)
+    // "automotive" rows appear at both ends of the segment, so the boundary 
is the boundary of the segment.
+    assertTimeBoundary(
+        new SelectorDimFilter("quality", "automotive", null),
+        null,
+        DateTimes.of("2011-01-12T00:00:00.000Z"),
+        DateTimes.of("2011-04-15T00:00:00.000Z")
     );
-    timeline.add(
-        index1.getInterval(),
-        "v1",
-        new SingleElementPartitionChunk<>(dataSegment1)
-    );
-
-    return QueryRunnerTestHelper.makeFilteringQueryRunner(timeline, 
referenceProviders, FACTORY);
   }
 
   @Test
-  @SuppressWarnings("unchecked")
-  public void testFilteredTimeBoundaryQuery() throws IOException
+  public void testFilteredTimeBoundaryQueryNarrowerThanSegment()
   {
-    QueryRunner customRunner = getCustomRunner();
-    TimeBoundaryQuery timeBoundaryQuery = Druids.newTimeBoundaryQueryBuilder()
-                                                .dataSource("testing")
-                                                .filters("quality", 
"automotive")
-                                                .build();
-    Assertions.assertTrue(timeBoundaryQuery.hasFilters());
-    List<Result<TimeBoundaryResultValue>> results =
-        customRunner.run(QueryPlus.wrap(timeBoundaryQuery)).toList();
-
-    Assertions.assertTrue(Iterables.size(results) > 0);
-
-    TimeBoundaryResultValue val = results.iterator().next().getValue();
-    DateTime minTime = val.getMinTime();
-    DateTime maxTime = val.getMaxTime();
-
-    Assertions.assertEquals(DateTimes.of("2011-01-13T00:00:00.000Z"), minTime);
-    Assertions.assertEquals(DateTimes.of("2011-01-16T00:00:00.000Z"), maxTime);
+    // Only four rows have "index" >= 1700, and they all lie strictly inside 
the segment: the earliest is on
+    // 2011-01-30 and the latest is on 2011-03-31.
+    assertTimeBoundary(
+        new RangeFilter("index", ColumnType.DOUBLE, 1700.0, null, false, null, 
null),
+        null,
+        DateTimes.of("2011-01-30T00:00:00.000Z"),
+        DateTimes.of("2011-03-31T00:00:00.000Z")
+    );
   }
 
   @Test
-  @SuppressWarnings("unchecked")
-  public void testTimeFilteredTimeBoundaryQuery() throws IOException
+  public void testTimeFilteredTimeBoundaryQuery()
   {
-    QueryRunner customRunner = getCustomRunner();
-    TimeBoundaryQuery timeBoundaryQuery = Druids.newTimeBoundaryQueryBuilder()
-                                                .dataSource("testing")
-                                                .intervals(
-                                                    new 
MultipleIntervalSegmentSpec(
-                                                        
ImmutableList.of(Intervals.of(
-                                                            
"2011-01-15T00:00:00.000Z/2011-01-16T00:00:00.000Z"))
-                                                    )
-                                                )
-                                                .build();
-    List<Result<TimeBoundaryResultValue>> results =
-        customRunner.run(QueryPlus.wrap(timeBoundaryQuery)).toList();
-
-    Assertions.assertTrue(Iterables.size(results) > 0);
-
-    TimeBoundaryResultValue val = results.iterator().next().getValue();
-    DateTime minTime = val.getMinTime();
-    DateTime maxTime = val.getMaxTime();
-
-    Assertions.assertEquals(DateTimes.of("2011-01-15T00:00:00.000Z"), minTime);
-    Assertions.assertEquals(DateTimes.of("2011-01-15T01:00:00.000Z"), maxTime);
+    // There are no rows on the edges of the query interval: the earliest row 
inside it is on 2011-01-20T01, and the
+    // latest is on 2011-01-22.
+    assertTimeBoundary(
+        null,
+        Intervals.of("2011-01-20T00:00:00.000Z/2011-01-23T00:00:00.000Z"),
+        DateTimes.of("2011-01-20T01:00:00.000Z"),
+        DateTimes.of("2011-01-22T00:00:00.000Z")
+    );
   }
 
   @Test
-  @SuppressWarnings("unchecked")
-  public void testFilteredTimeBoundaryQueryNoMatches() throws IOException
+  public void testFilteredTimeBoundaryQueryNoMatches()
   {
-    QueryRunner customRunner = getCustomRunner();
-    TimeBoundaryQuery timeBoundaryQuery = Druids.newTimeBoundaryQueryBuilder()
-                                                .dataSource("testing")
-                                                .filters("quality", "foobar") 
// foobar dimension does not exist
-                                                .build();
-    Assertions.assertTrue(timeBoundaryQuery.hasFilters());
-    List<Result<TimeBoundaryResultValue>> results =
-        customRunner.run(QueryPlus.wrap(timeBoundaryQuery)).toList();
-
-    Assertions.assertTrue(Iterables.size(results) == 0);
+    // "foobar" quality does not exist.
+    assertTimeBoundary(new SelectorDimFilter("quality", "foobar", null), null, 
null, null);
   }
 
   @Test
@@ -435,4 +318,66 @@ public class TimeBoundaryQueryRunnerTest extends 
InitializedNullHandlingTest
 
     Assertions.assertFalse(actual.iterator().hasNext());
   }
+
+  /**
+   * Run a time boundary query against {@link #runner} for every "bound" and 
every vectorization mode that the
+   * runner's segment supports, and verify the min and max time. Null 
expectations mean that the query is expected to
+   * return no results at all.
+   */
+  private void assertTimeBoundary(
+      @Nullable final DimFilter filter,
+      @Nullable final Interval interval,
+      @Nullable final DateTime expectedMinTime,
+      @Nullable final DateTime expectedMaxTime
+  )
+  {
+    final List<String> vectorizeValues = new 
ArrayList<>(Arrays.asList("false", "true"));
+
+    if (runner.getSegment().as(QueryableIndex.class) != null) {
+      vectorizeValues.add("force");
+    }
+
+    for (final String bound : Arrays.asList(TimeBoundaryQuery.MIN_TIME, 
TimeBoundaryQuery.MAX_TIME, null)) {
+      for (final String vectorize : vectorizeValues) {
+        final String message = StringUtils.join(new Object[]{runner.getName(), 
bound, vectorize}, ' ');
+        final TimeBoundaryQuery query =
+            Druids.newTimeBoundaryQueryBuilder()
+                  .dataSource(QueryRunnerTestHelper.DATA_SOURCE)
+                  .filters(filter)
+                  .intervals(
+                      interval == null
+                      ? null
+                      : new 
MultipleIntervalSegmentSpec(ImmutableList.of(interval))
+                  )
+                  .bound(bound)
+                  .context(
+                      ImmutableMap.of(
+                          QueryContexts.VECTORIZE_KEY, vectorize,
+                          QueryContexts.VECTOR_SIZE_KEY, 7
+                      )
+                  )
+                  .build();
+
+        Assertions.assertEquals(filter != null, query.hasFilters(), message);
+
+        final ResponseContext context = 
ConcurrentResponseContext.createEmpty();
+        context.initializeMissingSegments();
+        final List<Result<TimeBoundaryResultValue>> results =
+            runner.run(QueryPlus.wrap(query), context).toList();
+
+        final DateTime expectedMinTimeForBound =
+            TimeBoundaryQuery.MAX_TIME.equals(bound) ? null : expectedMinTime;
+        final DateTime expectedMaxTimeForBound =
+            TimeBoundaryQuery.MIN_TIME.equals(bound) ? null : expectedMaxTime;
+
+        if (expectedMinTimeForBound == null && expectedMaxTimeForBound == 
null) {
+          Assertions.assertEquals(Collections.emptyList(), results, message);
+        } else {
+          final TimeBoundaryResultValue val = 
Iterables.getOnlyElement(results).getValue();
+          Assertions.assertEquals(expectedMinTimeForBound, val.getMinTime(), 
message);
+          Assertions.assertEquals(expectedMaxTimeForBound, val.getMaxTime(), 
message);
+        }
+      }
+    }
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/query/timeseries/TimeseriesQueryRunnerTest.java
 
b/processing/src/test/java/org/apache/druid/query/timeseries/TimeseriesQueryRunnerTest.java
index da5d70f9079..f5f6615e53b 100644
--- 
a/processing/src/test/java/org/apache/druid/query/timeseries/TimeseriesQueryRunnerTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/timeseries/TimeseriesQueryRunnerTest.java
@@ -133,9 +133,7 @@ public class TimeseriesQueryRunnerTest extends 
InitializedNullHandlingTest
         .stream(baseConstructors.spliterator(), false)
         .filter(
             constructor -> {
-              boolean canVectorize =
-                  QueryRunnerTestHelper.isTestRunnerVectorizable((QueryRunner) 
constructor[0])
-                  && !(boolean) constructor[1] /* descending */;
+              boolean canVectorize = 
QueryRunnerTestHelper.isTestRunnerVectorizable((QueryRunner) constructor[0]);
               final boolean vectorize = (boolean) constructor[2]; /* vectorize 
*/
               final boolean useVectorApi = (boolean) constructor[4]; /* 
useVectorApi */
               if (!vectorize && useVectorApi) {
diff --git 
a/processing/src/test/java/org/apache/druid/segment/DescendingVectorCursorDiffTest.java
 
b/processing/src/test/java/org/apache/druid/segment/DescendingVectorCursorDiffTest.java
new file mode 100644
index 00000000000..fed6d7a0ef1
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/DescendingVectorCursorDiffTest.java
@@ -0,0 +1,306 @@
+/*
+ * 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.segment;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.MapBasedInputRow;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.filter.Filter;
+import org.apache.druid.query.filter.InDimFilter;
+import org.apache.druid.query.filter.NotDimFilter;
+import org.apache.druid.query.filter.NullFilter;
+import org.apache.druid.query.filter.RangeFilter;
+import org.apache.druid.query.filter.SelectorDimFilter;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.filter.AndFilter;
+import org.apache.druid.segment.incremental.IncrementalIndexSchema;
+import org.apache.druid.segment.shim.ShimCursor;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.apache.druid.segment.virtual.NestedFieldVirtualColumn;
+import org.apache.druid.testing.InitializedNullHandlingTest;
+import org.joda.time.Interval;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.annotation.Nullable;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Verifies that a time-descending vectorized cursor over a {@link 
QueryableIndex} produces exactly the same rows, in
+ * the same order, as the non-vectorized descending cursor.
+ */
+public class DescendingVectorCursorDiffTest extends InitializedNullHandlingTest
+{
+  private static final int[] VECTOR_SIZES = new int[]{1, 2, 3, 7, 512};
+
+  @TempDir
+  public File temporaryFolder;
+
+  @Test
+  public void testStandardColumns()
+  {
+    final VirtualColumns virtualColumns = VirtualColumns.create(
+        new ExpressionVirtualColumn("vc", "index * 2 + 1", ColumnType.DOUBLE, 
ExprMacroTable.nil())
+    );
+
+    assertVectorizedMatchesNonVectorized(
+        new QueryableIndexCursorFactory(TestIndex.getMMappedTestIndex()),
+        virtualColumns,
+        ImmutableList.of(
+            ColumnHolder.TIME_COLUMN_NAME,
+            "market",               // single-value string
+            "placementish",         // multi-value string
+            "partial_null_column",  // partially-null string
+            "qualityLong",
+            "longNumericNull",      // null-bearing long
+            "floatNumericNull",     // null-bearing float
+            "doubleNumericNull",    // null-bearing double
+            "index",                // double metric
+            "quality_uniques",      // complex column
+            "vc"                    // expression virtual column
+        ),
+        ImmutableMap.of(
+            // Bitmap-indexed filters use DescendingBitmapVectorOffset.
+            "bitmapIndex", new SelectorDimFilter("market", "spot", 
null).toFilter(),
+            "bitmapIndexSelective", new InDimFilter("quality", 
ImmutableSet.of("automotive")).toFilter(),
+            "nullBitmapIndex", new NotDimFilter(new 
NullFilter("longNumericNull", null)).toFilter(),
+
+            // Filters without an index use FilteredVectorOffset on top of a 
descending offset.
+            "valueMatcher", new RangeFilter("index", ColumnType.DOUBLE, 100.0, 
1000.0, true, true, null),
+            "virtualColumnMatcher", new RangeFilter("vc", ColumnType.DOUBLE, 
500.0, null, true, false, null),
+            "bitmapIndexAndValueMatcher", new AndFilter(
+                ImmutableList.of(
+                    new SelectorDimFilter("market", "spot", null).toFilter(),
+                    new RangeFilter("index", ColumnType.DOUBLE, 100.0, null, 
true, false, null)
+                )
+            )
+        ),
+        // Strictly inside the segment interval, which runs from 2011-01-12 to 
2011-04-15.
+        Intervals.of("2011-01-13T00:00:00.000Z/2011-03-01T00:00:00.000Z")
+    );
+  }
+
+  @Test
+  public void testNestedColumns()
+  {
+    final VirtualColumns virtualColumns = VirtualColumns.create(
+        new NestedFieldVirtualColumn("nested", "$.x", "nestedX", 
ColumnType.LONG),
+        new NestedFieldVirtualColumn("nested", "$.y", "nestedY", 
ColumnType.STRING)
+    );
+
+    assertVectorizedMatchesNonVectorized(
+        new QueryableIndexCursorFactory(makeNestedIndex()),
+        virtualColumns,
+        ImmutableList.of(
+            ColumnHolder.TIME_COLUMN_NAME,
+            "str",
+            "lng",
+            "dbl",
+            "arr",
+            "nested",
+            "nestedX",
+            "nestedY"
+        ),
+        ImmutableMap.of(
+            "bitmapIndex", new SelectorDimFilter("str", "s3", null).toFilter(),
+            "valueMatcher", new RangeFilter("lng", ColumnType.LONG, 50L, null, 
true, false, null),
+            "nestedField", new SelectorDimFilter("nestedY", "a", 
null).toFilter()
+        ),
+        // Rows are one minute apart starting at 2000-01-01, so this clips to 
rows [37, 191).
+        Intervals.of("2000-01-01T00:37:00.000Z/2000-01-01T03:11:00.000Z")
+    );
+  }
+
+  /**
+   * Runs the unfiltered case, plus each of the named {@code filters}, at each 
of the {@link #VECTOR_SIZES}. Each
+   * combination runs twice: once over the entire segment, and once over 
{@code narrowInterval}, which must lie
+   * strictly inside the segment so that the descending offset starts partway 
through the segment (nonzero start
+   * offset) and stops before its final row (clipped end offset).
+   */
+  private void assertVectorizedMatchesNonVectorized(
+      final CursorFactory cursorFactory,
+      final VirtualColumns virtualColumns,
+      final List<String> columns,
+      final Map<String, Filter> filters,
+      final Interval narrowInterval
+  )
+  {
+    final Map<String, Filter> allCases = new LinkedHashMap<>();
+    allCases.put("noFilter", null);
+    allCases.putAll(filters);
+
+    for (final Map.Entry<String, Filter> filterEntry : allCases.entrySet()) {
+      for (final int vectorSize : VECTOR_SIZES) {
+        // Row count for Intervals.ETERNITY, used to verify that 
"narrowInterval" actually narrows.
+        int fullSegmentRowCount = -1;
+
+        for (final Interval interval : ImmutableList.of(Intervals.ETERNITY, 
narrowInterval)) {
+          final String message = StringUtils.format(
+              "interval[%s] filter[%s] vectorSize[%d]",
+              interval,
+              filterEntry.getKey(),
+              vectorSize
+          );
+          final CursorBuildSpec buildSpec =
+              CursorBuildSpec.builder()
+                             .setInterval(interval)
+                             
.setPreferredOrdering(Cursors.descendingTimeOrder())
+                             .setVirtualColumns(virtualColumns)
+                             .setFilter(filterEntry.getValue())
+                             .setQueryContext(
+                                 
QueryContext.of(ImmutableMap.of(QueryContexts.VECTOR_SIZE_KEY, vectorSize))
+                             )
+                             .build();
+
+          final List<List<Object>> expected;
+          final List<List<Object>> actual;
+
+          try (final CursorHolder holder = 
cursorFactory.makeCursorHolder(buildSpec)) {
+            Assertions.assertEquals(Cursors.descendingTimeOrder(), 
holder.getOrdering(), message);
+            expected = readRows(holder.asCursor(), columns);
+          }
+
+          try (final CursorHolder holder = 
cursorFactory.makeCursorHolder(buildSpec)) {
+            Assertions.assertTrue(holder.canVectorize(), message);
+            actual = readRows(new ShimCursor(holder.asVectorCursor()), 
columns);
+          }
+
+          Assertions.assertFalse(expected.isEmpty(), message + ": expected 
some rows");
+          Assertions.assertEquals(expected.size(), actual.size(), message + ": 
row count");
+          for (int i = 0; i < expected.size(); i++) {
+            Assertions.assertEquals(expected.get(i), actual.get(i), message + 
": row " + i);
+          }
+
+          // Sanity check: __time is non-increasing.
+          long previousTime = Long.MAX_VALUE;
+          for (final List<Object> row : actual) {
+            final long time = ((Number) row.get(0)).longValue();
+            Assertions.assertTrue(time <= previousTime, message + ": 
descending __time");
+            previousTime = time;
+          }
+
+          if (Intervals.ETERNITY.equals(interval)) {
+            fullSegmentRowCount = actual.size();
+          } else {
+            // Guards against the narrow-interval case silently degrading into 
a second full-segment scan.
+            Assertions.assertTrue(
+                actual.size() < fullSegmentRowCount,
+                message + ": narrow interval must return fewer rows than the 
full segment"
+            );
+          }
+        }
+      }
+    }
+  }
+
+  private QueryableIndex makeNestedIndex()
+  {
+    final List<String> dimensions = ImmutableList.of("str", "lng", "dbl", 
"arr", "nested");
+    final List<InputRow> rows = new ArrayList<>();
+
+    for (int i = 0; i < 300; i++) {
+      final Map<String, Object> event = new HashMap<>();
+      event.put("str", i % 7 == 0 ? null : "s" + (i % 13));
+      event.put("lng", i % 5 == 0 ? null : (long) i);
+      event.put("dbl", i % 3 == 0 ? null : i * 1.5);
+      event.put("arr", i % 11 == 0 ? null : ImmutableList.of((long) i, (long) 
(i + 1)));
+      event.put("nested", i % 9 == 0 ? null : ImmutableMap.of("x", i, "y", i % 
4 == 0 ? "a" : "b"));
+      rows.add(new MapBasedInputRow(DateTimes.of("2000-01-01").plusMinutes(i), 
dimensions, event));
+    }
+
+    return IndexBuilder.create()
+                       .tmpDir(temporaryFolder)
+                       .schema(
+                           IncrementalIndexSchema
+                               .builder()
+                               .withDimensionsSpec(
+                                   DimensionsSpec.builder()
+                                                 .setDimensions(
+                                                     dimensions.stream()
+                                                               .map(d -> new 
AutoTypeColumnSchema(d, null, null))
+                                                               
.collect(Collectors.toList())
+                                                 )
+                                                 .build()
+                               )
+                               .withRollup(false)
+                               .build()
+                       )
+                       .rows(rows)
+                       .buildMMappedIndex();
+  }
+
+  private static List<List<Object>> readRows(final Cursor cursor, final 
List<String> columns)
+  {
+    final ColumnSelectorFactory columnSelectorFactory = 
cursor.getColumnSelectorFactory();
+    final List<ColumnValueSelector<?>> selectors = new 
ArrayList<>(columns.size());
+    for (final String column : columns) {
+      selectors.add(columnSelectorFactory.makeColumnValueSelector(column));
+    }
+
+    final List<List<Object>> rows = new ArrayList<>();
+    while (!cursor.isDone()) {
+      final List<Object> row = new ArrayList<>(columns.size());
+      for (final ColumnValueSelector<?> selector : selectors) {
+        row.add(comparableValue(selector.getObject()));
+      }
+      rows.add(row);
+      cursor.advance();
+    }
+    return rows;
+  }
+
+  /**
+   * Converts a selector value into something with meaningful {@link 
Object#equals}.
+   */
+  @Nullable
+  private static Object comparableValue(@Nullable final Object o)
+  {
+    if (o instanceof Object[]) {
+      return comparableList(Arrays.asList((Object[]) o));
+    } else if (o instanceof List) {
+      return comparableList((List<?>) o);
+    } else {
+      return o;
+    }
+  }
+
+  private static List<Object> comparableList(final List<?> list)
+  {
+    return 
list.stream().map(DescendingVectorCursorDiffTest::comparableValue).collect(Collectors.toList());
+  }
+}
diff --git a/processing/src/test/java/org/apache/druid/segment/TestHelper.java 
b/processing/src/test/java/org/apache/druid/segment/TestHelper.java
index 6b3ef27f854..5cb2883a125 100644
--- a/processing/src/test/java/org/apache/druid/segment/TestHelper.java
+++ b/processing/src/test/java/org/apache/druid/segment/TestHelper.java
@@ -43,9 +43,7 @@ import org.apache.druid.query.topn.TopNResultValue;
 import org.apache.druid.segment.column.ColumnConfig;
 import org.apache.druid.segment.join.JoinableFactoryWrapper;
 import org.apache.druid.segment.writeout.SegmentWriteOutMediumFactory;
-import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.DataSegment.PruneSpecsHolder;
-import org.apache.druid.timeline.partition.ShardSpec;
 import org.junit.jupiter.api.Assertions;
 
 import java.io.IOException;
@@ -519,12 +517,4 @@ public class TestHelper
       throw new UncheckedIOException(e);
     }
   }
-
-  public static DataSegment toSimpleDataSegment(Segment segment, ShardSpec 
shardSpec)
-  {
-    return DataSegment.builder(segment.getId())
-                      .shardSpec(shardSpec)
-                      .size(0)
-                      .build();
-  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/segment/vector/DescendingVectorOffsetTest.java
 
b/processing/src/test/java/org/apache/druid/segment/vector/DescendingVectorOffsetTest.java
new file mode 100644
index 00000000000..c3110e395f6
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/vector/DescendingVectorOffsetTest.java
@@ -0,0 +1,253 @@
+/*
+ * 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.segment.vector;
+
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntList;
+import org.apache.druid.collections.bitmap.ImmutableBitmap;
+import org.apache.druid.collections.bitmap.WrappedImmutableRoaringBitmap;
+import org.apache.druid.error.DruidException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Verifies the tail-first, ascending-within-batch behavior of {@link 
DescendingNoFilterVectorOffset} and
+ * {@link DescendingBitmapVectorOffset}. Combined with the value reversal done 
by
+ * {@link ReverseVectorColumnSelectorFactory}, iterating these offsets yields 
rows in fully descending order.
+ */
+public class DescendingVectorOffsetTest
+{
+  private static final int BITMAP_ROWS = 1000;
+
+  private static final List<Integer> NO_FILTER_VECTOR_SIZES = List.of(1, 2, 3, 
7, 16);
+  private static final List<Integer> NO_FILTER_ENDS = List.of(0, 1, 5, 16, 17, 
100);
+  private static final List<Integer> NO_FILTER_STARTS = List.of(0, 1, 3);
+  private static final List<Integer> BITMAP_VECTOR_SIZES = List.of(1, 2, 7, 
16);
+
+  /**
+   * Every bit set, and every third one.
+   */
+  private static final List<Integer> BITMAP_STEPS = List.of(1, 3);
+
+  /**
+   * The whole bitmap, an interior range, a single-row range, the last row, 
and two empty ranges.
+   */
+  private static final List<int[]> BITMAP_RANGES = List.of(
+      new int[]{0, BITMAP_ROWS},
+      new int[]{10, 500},
+      new int[]{7, 8},
+      new int[]{999, 1000},
+      new int[]{500, 500},
+      new int[]{0, 0}
+  );
+
+  public static Stream<Arguments> noFilterParameters()
+  {
+    return NO_FILTER_VECTOR_SIZES.stream().flatMap(
+        vectorSize -> NO_FILTER_ENDS.stream().flatMap(
+            end -> NO_FILTER_STARTS.stream()
+                                   .filter(start -> start <= end)
+                                   .map(start -> Arguments.of(vectorSize, 
start, end))
+        )
+    );
+  }
+
+  public static Stream<Arguments> bitmapParameters()
+  {
+    return BITMAP_STEPS.stream().flatMap(
+        step -> BITMAP_VECTOR_SIZES.stream().flatMap(
+            vectorSize -> BITMAP_RANGES.stream().map(range -> 
Arguments.of(step, vectorSize, range[0], range[1]))
+        )
+    );
+  }
+
+  @ParameterizedTest(name = "vectorSize[{0}] range[{1},{2})")
+  @MethodSource("noFilterParameters")
+  public void testNoFilterProducesDescendingOrder(final int vectorSize, final 
int start, final int end)
+  {
+    final DescendingNoFilterVectorOffset offset = new 
DescendingNoFilterVectorOffset(vectorSize, start, end);
+
+    // Reversing each batch's ascending range and concatenating across batches 
must yield [end-1 .. start].
+    final IntList logical = new IntArrayList();
+    while (!offset.isDone()) {
+      Assertions.assertTrue(offset.isContiguous(), "isContiguous");
+      Assertions.assertThrows(UnsupportedOperationException.class, 
offset::getOffsets);
+
+      final int startOffset = offset.getStartOffset();
+      final int size = offset.getCurrentVectorSize();
+      Assertions.assertTrue(size > 0 && size <= vectorSize, "0 < size[" + size 
+ "] <= vectorSize");
+
+      // Batches are aligned to multiples of vectorSize, so that they do not 
straddle compressed block boundaries.
+      // Only the batch that runs into "start" may be unaligned.
+      Assertions.assertTrue(
+          startOffset == start || startOffset % vectorSize == 0,
+          "aligned startOffset[" + startOffset + "]"
+      );
+
+      for (int i = size - 1; i >= 0; i--) {
+        logical.add(startOffset + i);
+      }
+      offset.advance();
+    }
+
+    final IntList expected = new IntArrayList();
+    for (int i = end - 1; i >= start; i--) {
+      expected.add(i);
+    }
+    Assertions.assertEquals(expected, logical);
+  }
+
+  @ParameterizedTest(name = "step[{0}] vectorSize[{1}] range[{2},{3})")
+  @MethodSource("bitmapParameters")
+  public void testBitmapProducesDescendingOrder(
+      final int step,
+      final int vectorSize,
+      final int start,
+      final int end
+  )
+  {
+    final ImmutableBitmap bitmap = makeBitmap(BITMAP_ROWS, step);
+    final DescendingBitmapVectorOffset offset = new 
DescendingBitmapVectorOffset(vectorSize, bitmap, start, end);
+
+    final IntList logical = new IntArrayList();
+    while (!offset.isDone()) {
+      final int size = offset.getCurrentVectorSize();
+      Assertions.assertTrue(size > 0 && size <= vectorSize, "0 < size[" + size 
+ "] <= vectorSize");
+
+      final int[] batch = currentBatch(offset);
+
+      // Within a batch, offsets are ascending (required by column readers).
+      for (int i = 1; i < size; i++) {
+        Assertions.assertTrue(batch[i] > batch[i - 1], "ascending within 
batch");
+      }
+
+      // getId is the smallest offset of the batch.
+      Assertions.assertEquals(batch[0], offset.getId(), "getId");
+
+      for (int i = size - 1; i >= 0; i--) {
+        logical.add(batch[i]);
+      }
+      offset.advance();
+    }
+
+    // Expected: matching set bits in [start, end) in descending order.
+    final IntList expected = new IntArrayList();
+    for (int i = end - 1; i >= start; i--) {
+      if (bitmap.get(i)) {
+        expected.add(i);
+      }
+    }
+    Assertions.assertEquals(expected, logical);
+  }
+
+  @Test
+  public void testNoFilterReset()
+  {
+    final DescendingNoFilterVectorOffset offset = new 
DescendingNoFilterVectorOffset(4, 0, 10);
+    final IntList firstPass = drainNoFilter(offset);
+    offset.reset();
+    final IntList secondPass = drainNoFilter(offset);
+    Assertions.assertEquals(firstPass, secondPass);
+  }
+
+  @Test
+  public void testBitmapReset()
+  {
+    final ImmutableBitmap bitmap = makeBitmap(100, 3);
+    final DescendingBitmapVectorOffset offset = new 
DescendingBitmapVectorOffset(7, bitmap, 5, 90);
+    final IntList firstPass = drainBitmap(offset);
+    offset.reset();
+    final IntList secondPass = drainBitmap(offset);
+    Assertions.assertEquals(firstPass, secondPass);
+    Assertions.assertFalse(firstPass.isEmpty());
+  }
+
+  @Test
+  public void testBitmapDetectsContiguousBatches()
+  {
+    final DescendingBitmapVectorOffset offset = new 
DescendingBitmapVectorOffset(4, makeBitmap(100, 1), 0, 100);
+    Assertions.assertTrue(offset.isContiguous());
+    Assertions.assertEquals(96, offset.getStartOffset());
+    Assertions.assertEquals(4, offset.getCurrentVectorSize());
+    Assertions.assertThrows(DruidException.class, offset::getOffsets);
+  }
+
+  private static ImmutableBitmap makeBitmap(final int rows, final int step)
+  {
+    final MutableRoaringBitmap wrapped = new MutableRoaringBitmap();
+    for (int i = 0; i < rows; i++) {
+      if (i % step == 0) {
+        wrapped.add(i);
+      }
+    }
+    return new 
WrappedImmutableRoaringBitmap(wrapped.toImmutableRoaringBitmap());
+  }
+
+  private static int[] currentBatch(final VectorOffset offset)
+  {
+    final int size = offset.getCurrentVectorSize();
+    final int[] batch = new int[size];
+    if (offset.isContiguous()) {
+      Assertions.assertThrows(DruidException.class, offset::getOffsets);
+      final int startOffset = offset.getStartOffset();
+      for (int i = 0; i < size; i++) {
+        batch[i] = startOffset + i;
+      }
+    } else {
+      Assertions.assertThrows(DruidException.class, offset::getStartOffset);
+      System.arraycopy(offset.getOffsets(), 0, batch, 0, size);
+    }
+    return batch;
+  }
+
+  private static IntList drainNoFilter(final DescendingNoFilterVectorOffset 
offset)
+  {
+    final IntList logical = new IntArrayList();
+    while (!offset.isDone()) {
+      final int startOffset = offset.getStartOffset();
+      final int size = offset.getCurrentVectorSize();
+      for (int i = size - 1; i >= 0; i--) {
+        logical.add(startOffset + i);
+      }
+      offset.advance();
+    }
+    return logical;
+  }
+
+  private static IntList drainBitmap(final DescendingBitmapVectorOffset offset)
+  {
+    final IntList logical = new IntArrayList();
+    while (!offset.isDone()) {
+      final int[] batch = currentBatch(offset);
+      for (int i = batch.length - 1; i >= 0; i--) {
+        logical.add(batch[i]);
+      }
+      offset.advance();
+    }
+    return logical;
+  }
+}
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
index 0c21e6a90a6..b1980846207 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
@@ -3966,8 +3966,10 @@ public class CalciteJoinQueryTest extends 
BaseCalciteQueryTest
   @ParameterizedTest(name = "{0}")
   public void testTwoSemiJoinsSimultaneously(Map<String, Object> queryContext)
   {
-    // Cannot vectorize timeBoundary with maxTime (the engine will request 
descending order, which cannot vectorize).
-    cannotVectorize();
+    if (!isRewriteJoinToFilter(queryContext)) {
+      // Rewriting the joins to filters allows all queries to vectorize.
+      cannotVectorize();
+    }
 
     Map<String, Object> updatedQueryContext = new HashMap<>(queryContext);
     updatedQueryContext.put(QueryContexts.TIME_BOUNDARY_PLANNING_KEY, true);
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
index 3c2cff27729..b033aa4ea9a 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
@@ -10034,9 +10034,6 @@ public class CalciteQueryTest extends 
BaseCalciteQueryTest
   @Test
   public void testTimeseriesDescending()
   {
-    // Cannot vectorize due to descending order.
-    cannotVectorize();
-
     testQuery(
         "SELECT gran, SUM(cnt) FROM (\n"
         + "  SELECT floor(__time TO month) AS gran,\n"
@@ -12391,9 +12388,6 @@ public class CalciteQueryTest extends 
BaseCalciteQueryTest
   @Test
   public void testPostAggWithTimeseries()
   {
-    // Cannot vectorize due to descending order.
-    cannotVectorize();
-
     testQuery(
         "SELECT "
         + "  FLOOR(__time TO YEAR), "
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteTimeBoundaryQueryTest.java
 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteTimeBoundaryQueryTest.java
index e555ab65a96..c3c4ba6a575 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteTimeBoundaryQueryTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteTimeBoundaryQueryTest.java
@@ -198,7 +198,7 @@ public class CalciteTimeBoundaryQueryTest extends 
BaseCalciteQueryTest
   @Test
   public void testMaxTimeQueryWithJoin()
   {
-    // Cannot vectorize timeBoundary with maxTime (the engine will request 
descending order, which cannot vectorize).
+    // Cannot vectorize join.
     cannotVectorize();
 
     HashMap<String, Object> context = new HashMap<>(QUERY_CONTEXT_DEFAULT);


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

Reply via email to