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 9318bbee1a2 perf: Faster advanceIfNeeded for Roaring iterators. 
(#20129)
9318bbee1a2 is described below

commit 9318bbee1a26e959b2526ac7cf7045dc906147f2
Author: Gian Merlino <[email protected]>
AuthorDate: Fri Aug 28 20:29:10 2026 -0700

    perf: Faster advanceIfNeeded for Roaring iterators. (#20129)
    
    This patch adds SeekableRoaringIntIterator and uses it instead of
    ImmutableRoaringBitmap#getIntIterator in WrappedImmutableRoaringBitmap.
    
    The seekable iterator performs better than the builtin iterator,
    because it uses PointableRoaringArray#advanceUntil to advance containers,
    which is capable of binary search.
    
    This patch also updates various code paths that reset bitmap iterators
    in vectorized code paths to get them all using common logic: they should
    reset the iterator if the new start is prior to the old end, rather than
    comparing start-to-start or end-to-end.
---
 .../druid/benchmark/query/TimeseriesBenchmark.java | 145 ++---
 .../bitmap/SeekableRoaringIntIterator.java         | 179 ++++++
 .../bitmap/WrappedImmutableRoaringBitmap.java      |   2 +-
 .../apache/druid/segment/data/ColumnarDoubles.java |   8 +-
 .../apache/druid/segment/data/ColumnarFloats.java  |   8 +-
 .../apache/druid/segment/data/ColumnarLongs.java   |   8 +-
 .../org/apache/druid/segment/filter/OrFilter.java  |  24 +-
 .../nested/NestedFieldDictionaryEncodedColumn.java |  43 +-
 .../druid/segment/nested/ScalarDoubleColumn.java   |  11 +-
 .../druid/segment/nested/ScalarLongColumn.java     |   9 +-
 .../apache/druid/segment/nested/VariantColumn.java |  15 +-
 .../buffer/DruidRoaringBufferAccess.java           |  38 ++
 .../bitmap/SeekableRoaringIntIteratorTest.java     | 597 +++++++++++++++++++++
 .../segment/filter/OrFilterVectorMatcherTest.java  | 133 +++++
 14 files changed, 1089 insertions(+), 131 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 54bfca56f86..3a8c1b8c78e 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
@@ -19,12 +19,9 @@
 
 package org.apache.druid.benchmark.query;
 
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.apache.druid.jackson.DefaultObjectMapper;
-import org.apache.druid.java.util.common.FileUtils;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.concurrent.Execs;
+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.Sequence;
 import org.apache.druid.java.util.common.logger.Logger;
@@ -48,6 +45,7 @@ import 
org.apache.druid.query.aggregation.hyperloglog.HyperUniquesSerde;
 import org.apache.druid.query.context.ResponseContext;
 import org.apache.druid.query.filter.BoundDimFilter;
 import org.apache.druid.query.filter.DimFilter;
+import org.apache.druid.query.filter.OrDimFilter;
 import org.apache.druid.query.filter.SelectorDimFilter;
 import org.apache.druid.query.ordering.StringComparators;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
@@ -58,23 +56,17 @@ import 
org.apache.druid.query.timeseries.TimeseriesQueryQueryToolChest;
 import org.apache.druid.query.timeseries.TimeseriesQueryRunnerFactory;
 import org.apache.druid.query.timeseries.TimeseriesResultValue;
 import org.apache.druid.segment.IncrementalIndexSegment;
-import org.apache.druid.segment.IndexIO;
-import org.apache.druid.segment.IndexMergerV9;
-import org.apache.druid.segment.IndexSpec;
 import org.apache.druid.segment.QueryableIndex;
 import org.apache.druid.segment.QueryableIndexSegment;
-import org.apache.druid.segment.column.ColumnConfig;
 import org.apache.druid.segment.column.ColumnHolder;
-import org.apache.druid.segment.generator.DataGenerator;
 import org.apache.druid.segment.generator.GeneratorBasicSchemas;
 import org.apache.druid.segment.generator.GeneratorSchemaInfo;
-import org.apache.druid.segment.incremental.AppendableIndexSpec;
+import org.apache.druid.segment.generator.SegmentGenerator;
 import org.apache.druid.segment.incremental.IncrementalIndex;
-import org.apache.druid.segment.incremental.IncrementalIndexCreator;
-import org.apache.druid.segment.incremental.OnheapIncrementalIndex;
 import org.apache.druid.segment.serde.ComplexMetrics;
-import 
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
+import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
+import org.apache.druid.timeline.partition.LinearShardSpec;
 import org.openjdk.jmh.annotations.Benchmark;
 import org.openjdk.jmh.annotations.BenchmarkMode;
 import org.openjdk.jmh.annotations.Fork;
@@ -89,7 +81,6 @@ import org.openjdk.jmh.annotations.TearDown;
 import org.openjdk.jmh.annotations.Warmup;
 import org.openjdk.jmh.infra.Blackhole;
 
-import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -118,7 +109,13 @@ public class TimeseriesBenchmark
   @Param({"750000"})
   private int rowsPerSegment;
 
-  @Param({"basic.A", "basic.timeFilterNumeric", 
"basic.timeFilterAlphanumeric", "basic.timeFilterByInterval"})
+  @Param({
+      "basic.A",
+      "basic.timeFilterNumeric",
+      "basic.timeFilterAlphanumeric",
+      "basic.timeFilterByInterval",
+      "basic.orFilterPartialIndex"
+  })
   private String schemaAndQuery;
 
   @Param({"true", "false"})
@@ -131,28 +128,11 @@ public class TimeseriesBenchmark
   private String queryGranularity;
 
   private static final Logger log = new Logger(TimeseriesBenchmark.class);
-  private static final int RNG_SEED = 9999;
-  private static final IndexMergerV9 INDEX_MERGER_V9;
-  private static final IndexIO INDEX_IO;
-  public static final ObjectMapper JSON_MAPPER;
 
-  private AppendableIndexSpec appendableIndexSpec;
-  private DataGenerator generator;
   private QueryRunnerFactory factory;
   private GeneratorSchemaInfo schemaInfo;
   private TimeseriesQuery query;
 
-  static {
-    JSON_MAPPER = new DefaultObjectMapper();
-    INDEX_IO = new IndexIO(
-        JSON_MAPPER,
-        new ColumnConfig()
-        {
-        }
-    );
-    INDEX_MERGER_V9 = new IndexMergerV9(JSON_MAPPER, INDEX_IO, 
OffHeapMemorySegmentWriteOutMediumFactory.instance());
-  }
-
   private static final Map<String, Map<String, TimeseriesQuery>> 
SCHEMA_QUERY_MAP = new LinkedHashMap<>();
 
   private void setupQueries()
@@ -243,6 +223,33 @@ public class TimeseriesBenchmark
 
       basicQueries.put("timeFilterByInterval", timeFilterQuery);
     }
+    {
+      // One clause has a bitmap index, the other is a numeric column and has 
none. OrFilter therefore unions the
+      // indexable clauses into a partial index and converts that into a value 
matcher, which walks a bitmap iterator
+      // alongside the cursor offset. See 
OrFilter#convertIndexToVectorValueMatcher.
+      QuerySegmentSpec intervalSpec = new 
MultipleIntervalSegmentSpec(Collections.singletonList(basicSchema.getDataInterval()));
+
+      List<AggregatorFactory> queryAggs = new ArrayList<>();
+      queryAggs.add(new LongSumAggregatorFactory("sumLongSequential", 
"sumLongSequential"));
+
+      DimFilter orFilter = new OrDimFilter(
+          new SelectorDimFilter("dimSequential", "311", null),
+          new BoundDimFilter("maxLongUniform", "100", null, false, false, 
null, null, StringComparators.NUMERIC)
+      );
+
+      TimeseriesQuery orFilterQuery =
+          Druids.newTimeseriesQueryBuilder()
+                .dataSource("blah")
+                .granularity(Granularity.fromString(queryGranularity))
+                .intervals(intervalSpec)
+                .filters(orFilter)
+                .aggregators(queryAggs)
+                .descending(descending)
+                .context(Map.of("vectorize", vectorize))
+                .build();
+
+      basicQueries.put("orFilterPartialIndex", orFilterQuery);
+    }
 
 
     SCHEMA_QUERY_MAP.put("basic", basicQueries);
@@ -268,13 +275,6 @@ public class TimeseriesBenchmark
     schemaInfo = GeneratorBasicSchemas.SCHEMA_MAP.get(schemaName);
     query = SCHEMA_QUERY_MAP.get(schemaName).get(queryName);
 
-    generator = new DataGenerator(
-        schemaInfo.getColumnSchemas(),
-        RNG_SEED,
-        schemaInfo.getDataInterval(),
-        rowsPerSegment
-    );
-
     factory = new TimeseriesQueryRunnerFactory(
         new TimeseriesQueryQueryToolChest(),
         new TimeseriesQueryEngine(),
@@ -288,27 +288,31 @@ public class TimeseriesBenchmark
   @State(Scope.Benchmark)
   public static class IncrementalIndexState
   {
-    @Param({"onheap"})
-    private String indexType;
+    private SegmentGenerator segmentGenerator;
 
     IncrementalIndex incIndex;
 
     @Setup
-    public void setup(TimeseriesBenchmark global) throws 
JsonProcessingException
+    public void setup(TimeseriesBenchmark global)
     {
-      // Creates an AppendableIndexSpec that corresponds to the indexType 
parametrization.
-      // It is used in {@code global.makeIncIndex()} to instanciate an 
incremental-index of the specified type.
-      global.appendableIndexSpec = 
IncrementalIndexCreator.parseIndexType(indexType);
-      incIndex = global.makeIncIndex();
-      global.generator.addToIndex(incIndex, global.rowsPerSegment);
+      segmentGenerator = new SegmentGenerator();
+      incIndex = segmentGenerator.generateIncrementalIndex(
+          global.makeDataSegment(0),
+          global.schemaInfo,
+          Granularities.NONE,
+          global.rowsPerSegment
+      );
     }
 
     @TearDown
-    public void tearDown()
+    public void tearDown() throws IOException
     {
       if (incIndex != null) {
         incIndex.close();
       }
+      if (segmentGenerator != null) {
+        segmentGenerator.close();
+      }
     }
   }
 
@@ -322,57 +326,54 @@ public class TimeseriesBenchmark
     private int numSegments;
 
     private ExecutorService executorService;
-    private File qIndexesDir;
+    private SegmentGenerator segmentGenerator;
     private List<QueryableIndex> qIndexes;
 
     @Setup
-    public void setup(TimeseriesBenchmark global) throws IOException
+    public void setup(TimeseriesBenchmark global)
     {
-      global.appendableIndexSpec = new OnheapIncrementalIndex.Spec();
-
       executorService = Execs.multiThreaded(numSegments, 
"TimeseriesThreadPool");
 
-      qIndexesDir = FileUtils.createTempDir();
+      segmentGenerator = new SegmentGenerator();
       qIndexes = new ArrayList<>();
 
       for (int i = 0; i < numSegments; i++) {
         log.info("Generating rows for segment " + i);
 
-        IncrementalIndex incIndex = global.makeIncIndex();
-        global.generator.reset(RNG_SEED + i).addToIndex(incIndex, 
global.rowsPerSegment);
-
-        File indexFile = INDEX_MERGER_V9.persist(
-            incIndex,
-            new File(qIndexesDir, String.valueOf(i)),
-            IndexSpec.getDefault(),
-            null
+        qIndexes.add(
+            segmentGenerator.generate(
+                global.makeDataSegment(i),
+                global.schemaInfo,
+                Granularities.NONE,
+                global.rowsPerSegment
+            )
         );
-        incIndex.close();
-
-        qIndexes.add(INDEX_IO.loadIndex(indexFile));
       }
     }
 
     @TearDown
-    public void tearDown()
+    public void tearDown() throws IOException
     {
       for (QueryableIndex index : qIndexes) {
         if (index != null) {
           index.close();
         }
       }
-      if (qIndexesDir != null) {
-        qIndexesDir.delete();
+      if (segmentGenerator != null) {
+        segmentGenerator.close();
       }
     }
   }
 
-  private IncrementalIndex makeIncIndex()
+  private DataSegment makeDataSegment(final int segmentNumber)
   {
-    return appendableIndexSpec.builder()
-        .setSimpleTestingIndexSchema(schemaInfo.getAggsArray())
-        .setMaxRowCount(rowsPerSegment)
-        .build();
+    return DataSegment.builder()
+                      .dataSource("blah")
+                      .interval(schemaInfo.getDataInterval())
+                      .version("1")
+                      .shardSpec(new LinearShardSpec(segmentNumber))
+                      .size(0)
+                      .build();
   }
 
   private static <T> List<T> runQuery(QueryRunnerFactory factory, QueryRunner 
runner, Query<T> query)
diff --git 
a/processing/src/main/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIterator.java
 
b/processing/src/main/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIterator.java
new file mode 100644
index 00000000000..cb46f629801
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIterator.java
@@ -0,0 +1,179 @@
+/*
+ * 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.collections.bitmap;
+
+import org.roaringbitmap.PeekableCharIterator;
+import org.roaringbitmap.PeekableIntIterator;
+import org.roaringbitmap.buffer.DruidRoaringBufferAccess;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MappeableContainer;
+import org.roaringbitmap.buffer.PointableRoaringArray;
+
+import java.util.NoSuchElementException;
+
+/**
+ * A {@link PeekableIntIterator} over an {@link ImmutableRoaringBitmap} that 
repositions by finding the next container
+ * keys with {@link PointableRoaringArray#advanceUntil(char, int)}, which can 
binary search when appropriate.
+ * The builtin iterator from {@link ImmutableRoaringBitmap#getIntIterator()} 
is slower since it advances containers
+ * one at a time.
+ */
+public final class SeekableRoaringIntIterator implements PeekableIntIterator
+{
+  private final PointableRoaringArray highLowContainer;
+  private final int size;
+
+  /**
+   * Index of the container that {@link #iter} is reading.
+   */
+  private int index;
+
+  /**
+   * Key of the container at {@link #index}, shifted into the high bits.
+   */
+  private int shiftedKey;
+
+  /**
+   * Iterator over the (short) values of the current container.
+   */
+  private PeekableCharIterator iter;
+
+  /**
+   * Whether {@link #index} is in range. While true, {@code iter.hasNext()} is 
also true.
+   */
+  private boolean ok;
+
+  /**
+   * The container behind {@link #index}, retained so that rewinding within it 
does not have to materialize it again.
+   */
+  private MappeableContainer container;
+  private int containerIndex = -1;
+
+  public SeekableRoaringIntIterator(final ImmutableRoaringBitmap bitmap)
+  {
+    this.highLowContainer = DruidRoaringBufferAccess.highLowContainer(bitmap);
+    this.size = highLowContainer.size();
+    setContainer(0);
+  }
+
+  /**
+   * Positions this iterator so that {@link #peekNext()} returns the smallest 
value >= target, or {@link #hasNext()}
+   * returns false if the bitmap holds no such value. Unlike {@link 
#advanceIfNeeded}, target may be below the
+   * current position.
+   */
+  public void seek(final int target)
+  {
+    final int targetKey = target >>> 16;
+
+    if (ok && (shiftedKey >>> 16) == targetKey) {
+      if (iter.hasNext() && (iter.peekNext() & 0xFFFF) > (target & 0xFFFF)) {
+        // Attempting to seek within a container to a value earlier than the 
current iter position. Need to rewrap.
+        setContainer(index);
+      }
+    } else if (ok && targetKey > (shiftedKey >>> 16)) {
+      // Moving forwards one or more container(s).
+      setContainer(highLowContainer.advanceUntil((char) targetKey, index));
+    } else {
+      // Moving backwards one or more container(s).
+      final int found = highLowContainer.getContainerIndex((char) targetKey);
+      // A miss returns -(insertionPoint + 1), and the insertion point is the 
first container above targetKey.
+      setContainer(found >= 0 ? found : -found - 1);
+    }
+
+    if (ok && (shiftedKey >>> 16) == targetKey) {
+      iter.advanceIfNeeded((char) target);
+      if (!iter.hasNext()) {
+        setContainer(index + 1);
+      }
+    }
+  }
+
+  @Override
+  public void advanceIfNeeded(final int minval)
+  {
+    // Forward only: return early if minval is earlier than current iteration 
state.
+    if (!ok || Integer.compareUnsigned(peekNext(), minval) >= 0) {
+      return;
+    }
+    seek(minval);
+  }
+
+  @Override
+  public boolean hasNext()
+  {
+    return ok;
+  }
+
+  @Override
+  public int next()
+  {
+    if (!ok) {
+      throw new NoSuchElementException();
+    }
+    final int x = iter.nextAsInt() | shiftedKey;
+    if (!iter.hasNext()) {
+      setContainer(index + 1);
+    }
+    return x;
+  }
+
+  @Override
+  public int peekNext()
+  {
+    if (!ok) {
+      throw new NoSuchElementException();
+    }
+    return iter.peekNext() | shiftedKey;
+  }
+
+  @Override
+  public SeekableRoaringIntIterator clone()
+  {
+    try {
+      final SeekableRoaringIntIterator cloned = (SeekableRoaringIntIterator) 
super.clone();
+      if (iter != null) {
+        cloned.iter = iter.clone();
+      }
+      return cloned;
+    }
+    catch (CloneNotSupportedException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  /**
+   * Seeks to a new container, by index, and resets {@link #iter}.
+   */
+  private void setContainer(final int newIndex)
+  {
+    index = newIndex;
+    if (newIndex >= 0 && newIndex < size) {
+      if (newIndex != containerIndex) {
+        container = highLowContainer.getContainerAtIndex(newIndex);
+        containerIndex = newIndex;
+        shiftedKey = (highLowContainer.getKeyAtIndex(newIndex)) << 16;
+      }
+      iter = container.getCharIterator();
+      ok = iter.hasNext();
+    } else {
+      iter = null;
+      ok = false;
+    }
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/collections/bitmap/WrappedImmutableRoaringBitmap.java
 
b/processing/src/main/java/org/apache/druid/collections/bitmap/WrappedImmutableRoaringBitmap.java
index 97f7b65cfe2..6448f955a00 100644
--- 
a/processing/src/main/java/org/apache/druid/collections/bitmap/WrappedImmutableRoaringBitmap.java
+++ 
b/processing/src/main/java/org/apache/druid/collections/bitmap/WrappedImmutableRoaringBitmap.java
@@ -81,7 +81,7 @@ public class WrappedImmutableRoaringBitmap implements 
ImmutableBitmap
   @Override
   public PeekableIntIterator peekableIterator()
   {
-    return bitmap.getIntIterator();
+    return new SeekableRoaringIntIterator(bitmap);
   }
 
   @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 d0764ba492f..6a738091ec2 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
@@ -162,6 +162,9 @@ public interface ColumnarDoubles extends Closeable
       private int id = ReadableVectorInspector.NULL_ID;
 
       private PeekableIntIterator nullIterator = 
nullValueBitmap.peekableIterator();
+      /**
+       * One past the highest row id of the previous batch, or -1 before the 
first batch.
+       */
       private int offsetMark = -1;
 
       @Nullable
@@ -202,11 +205,10 @@ public interface ColumnarDoubles extends Closeable
           ColumnarDoubles.this.get(doubleVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-          if (maxOffset < offsetMark) {
+          if (offsets[0] < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = maxOffset;
+          offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
           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 1276f3294e7..539933aa1b4 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
@@ -157,6 +157,9 @@ public interface ColumnarFloats extends Closeable
       private int id = ReadableVectorInspector.NULL_ID;
 
       private PeekableIntIterator nullIterator = 
nullValueBitmap.peekableIterator();
+      /**
+       * One past the highest row id of the previous batch, or -1 before the 
first batch.
+       */
       private int offsetMark = -1;
 
       @Nullable
@@ -197,11 +200,10 @@ public interface ColumnarFloats extends Closeable
           ColumnarFloats.this.get(floatVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-          if (maxOffset < offsetMark) {
+          if (offsets[0] < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = maxOffset;
+          offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
           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 0c672195e8b..6351bed74ec 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
@@ -168,6 +168,9 @@ public interface ColumnarLongs extends Closeable
       private int id = ReadableVectorInspector.NULL_ID;
 
       private PeekableIntIterator nullIterator = 
nullValueBitmap.peekableIterator();
+      /**
+       * One past the highest row id of the previous batch, or -1 before the 
first batch.
+       */
       private int offsetMark = -1;
 
       @Nullable
@@ -208,11 +211,10 @@ public interface ColumnarLongs extends Closeable
           ColumnarLongs.this.get(longVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-          if (maxOffset < offsetMark) {
+          if (offsets[0] < offsetMark) {
             nullIterator = nullValueBitmap.peekableIterator();
           }
-          offsetMark = maxOffset;
+          offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
           ColumnarLongs.this.get(longVector, offsets, 
offset.getCurrentVectorSize());
         }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/filter/OrFilter.java 
b/processing/src/main/java/org/apache/druid/segment/filter/OrFilter.java
index 53e37036336..9d633e7e041 100644
--- a/processing/src/main/java/org/apache/druid/segment/filter/OrFilter.java
+++ b/processing/src/main/java/org/apache/druid/segment/filter/OrFilter.java
@@ -372,7 +372,7 @@ public class OrFilter implements BooleanFilter
     }
   }
 
-  private static VectorValueMatcher convertIndexToVectorValueMatcher(
+  static VectorValueMatcher convertIndexToVectorValueMatcher(
       final ReadableVectorOffset vectorOffset,
       final ImmutableBitmap bitmap
   )
@@ -386,7 +386,10 @@ public class OrFilter implements BooleanFilter
     {
       final VectorMatch match = VectorMatch.wrap(new 
int[vectorOffset.getMaxVectorSize()]);
       int iterOffset = -1;
-      int previousStartOffset = -1;
+      /**
+       * One past the highest row id of the previous batch.
+       */
+      int previousEndOffset = 0;
       PeekableIntIterator iterator = initialIterator;
 
       @Override
@@ -396,12 +399,12 @@ public class OrFilter implements BooleanFilter
 
         if (vectorOffset.isContiguous()) {
           final int startOffset = vectorOffset.getStartOffset();
-          // check if the cursor was reset, and reset iterator if so
-          if (startOffset <= previousStartOffset) {
+          // check if the cursor moved backwards, and reset iterator if so
+          if (startOffset < previousEndOffset) {
             iterOffset = -1;
             iterator = bitmap.peekableIterator();
           }
-          previousStartOffset = startOffset;
+          previousEndOffset = startOffset + getCurrentVectorSize();
           int numRows = 0;
           for (int i = 0; i < mask.getSelectionSize(); i++) {
             final int maskNum = mask.getSelection()[i];
@@ -418,11 +421,14 @@ public class OrFilter implements BooleanFilter
           return match;
         } else {
           final int[] currentOffsets = vectorOffset.getOffsets();
-          if (getCurrentVectorSize() > 0 && currentOffsets[0] <= 
previousStartOffset) {
-            iterOffset = -1;
-            iterator = bitmap.peekableIterator();
+          if (getCurrentVectorSize() > 0) {
+            // check if the cursor moved backwards, and reset iterator if so
+            if (currentOffsets[0] < previousEndOffset) {
+              iterOffset = -1;
+              iterator = bitmap.peekableIterator();
+            }
+            previousEndOffset = currentOffsets[getCurrentVectorSize() - 1] + 1;
           }
-          previousStartOffset = currentOffsets[0];
           int numRows = 0;
           for (int i = 0; i < mask.getSelectionSize(); i++) {
             final int maskNum = mask.getSelection()[i];
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 43a2080d7a6..5acf77be664 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
@@ -879,8 +879,10 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
           private boolean[] nullVector = null;
           private int id = ReadableVectorInspector.NULL_ID;
 
-          @Nullable
           private PeekableIntIterator nullIterator = 
nullBitmap.peekableIterator();
+          /**
+           * One past the highest row id of the previous batch, or -1 before 
the first batch.
+           */
           private int offsetMark = -1;
 
           @Override
@@ -912,17 +914,14 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
               longsColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
             } else {
               final int[] offsets = offset.getOffsets();
-              final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-              if (maxOffset < offsetMark) {
+              if (offsets[0] < offsetMark) {
                 nullIterator = nullBitmap.peekableIterator();
               }
-              offsetMark = maxOffset;
+              offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
               longsColumn.get(valueVector, offsets, 
offset.getCurrentVectorSize());
             }
 
-            if (nullIterator != null) {
-              nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
-            }
+            nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
 
             id = offset.getId();
           }
@@ -935,8 +934,10 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
           private boolean[] nullVector = null;
           private int id = ReadableVectorInspector.NULL_ID;
 
-          @Nullable
-          private PeekableIntIterator nullIterator = nullBitmap != null ? 
nullBitmap.peekableIterator() : null;
+          private PeekableIntIterator nullIterator = 
nullBitmap.peekableIterator();
+          /**
+           * One past the highest row id of the previous batch, or -1 before 
the first batch.
+           */
           private int offsetMark = -1;
 
           @Override
@@ -968,17 +969,14 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
               doublesColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
             } else {
               final int[] offsets = offset.getOffsets();
-              final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-              if (maxOffset < offsetMark) {
+              if (offsets[0] < offsetMark) {
                 nullIterator = nullBitmap.peekableIterator();
               }
-              offsetMark = maxOffset;
+              offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
               doublesColumn.get(valueVector, offsets, 
offset.getCurrentVectorSize());
             }
 
-            if (nullIterator != null) {
-              nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
-            }
+            nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
 
             id = offset.getId();
           }
@@ -995,8 +993,10 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
         private boolean[] nullVector = null;
         private int id = ReadableVectorInspector.NULL_ID;
 
-        @Nullable
-        private PeekableIntIterator nullIterator = nullBitmap != null ? 
nullBitmap.peekableIterator() : null;
+        private PeekableIntIterator nullIterator = 
nullBitmap.peekableIterator();
+        /**
+         * One past the highest row id of the previous batch, or -1 before the 
first batch.
+         */
         private int offsetMark = -1;
 
         @Override
@@ -1028,11 +1028,10 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
             column.get(idVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
           } else {
             final int[] offsets = offset.getOffsets();
-            final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-            if (maxOffset < offsetMark) {
+            if (offsets[0] < offsetMark) {
               nullIterator = nullBitmap.peekableIterator();
             }
-            offsetMark = maxOffset;
+            offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
             column.get(idVector, offsets, offset.getCurrentVectorSize());
           }
           for (int i = 0; i < offset.getCurrentVectorSize(); i++) {
@@ -1047,9 +1046,7 @@ public class 
NestedFieldDictionaryEncodedColumn<TStringDictionary extends Indexe
             }
           }
 
-          if (nullIterator != null) {
-            nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
-          }
+          nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
 
           id = offset.getId();
         }
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 4252cf56342..acc8171f768 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
@@ -137,8 +137,10 @@ public class ScalarDoubleColumn implements 
NestedCommonFormatColumn
       private boolean[] nullVector = null;
       private int id = ReadableVectorInspector.NULL_ID;
 
-      @Nullable
-      private PeekableIntIterator nullIterator = nullValueIndex != null ? 
nullValueIndex.peekableIterator() : null;
+      private PeekableIntIterator nullIterator = 
nullValueIndex.peekableIterator();
+      /**
+       * One past the highest row id of the previous batch, or -1 before the 
first batch.
+       */
       private int offsetMark = -1;
 
       @Override
@@ -170,11 +172,10 @@ public class ScalarDoubleColumn implements 
NestedCommonFormatColumn
           valueColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-          if (maxOffset < offsetMark) {
+          if (offsets[0] < offsetMark) {
             nullIterator = nullValueIndex.peekableIterator();
           }
-          offsetMark = maxOffset;
+          offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
           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 9fa57b7c731..2ebd4f8bc7b 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
@@ -138,8 +138,10 @@ public class ScalarLongColumn implements 
NestedCommonFormatColumn
       private boolean[] nullVector = null;
       private int id = ReadableVectorInspector.NULL_ID;
 
-      @Nullable
       private PeekableIntIterator nullIterator = 
nullValueIndex.peekableIterator();
+      /**
+       * One past the highest row id of the previous batch, or -1 before the 
first batch.
+       */
       private int offsetMark = -1;
 
       @Override
@@ -171,11 +173,10 @@ public class ScalarLongColumn implements 
NestedCommonFormatColumn
           valueColumn.get(valueVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
         } else {
           final int[] offsets = offset.getOffsets();
-          final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-          if (maxOffset < offsetMark) {
+          if (offsets[0] < offsetMark) {
             nullIterator = nullValueIndex.peekableIterator();
           }
-          offsetMark = maxOffset;
+          offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
           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 1bbf3ffe26e..b6e1532d719 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
@@ -797,8 +797,10 @@ public class VariantColumn<TStringDictionary extends 
Indexed<ByteBuffer>>
         private boolean[] nullVector = null;
         private int id = ReadableVectorInspector.NULL_ID;
 
-        @Nullable
-        private PeekableIntIterator nullIterator = nullValueBitmap != null ? 
nullValueBitmap.peekableIterator() : null;
+        private PeekableIntIterator nullIterator = 
nullValueBitmap.peekableIterator();
+        /**
+         * One past the highest row id of the previous batch, or -1 before the 
first batch.
+         */
         private int offsetMark = -1;
         @Override
         public double[] getDoubleVector()
@@ -829,11 +831,10 @@ public class VariantColumn<TStringDictionary extends 
Indexed<ByteBuffer>>
             encodedValueColumn.get(idVector, offset.getStartOffset(), 
offset.getCurrentVectorSize());
           } else {
             final int[] offsets = offset.getOffsets();
-            final int maxOffset = offsets[offset.getCurrentVectorSize() - 1];
-            if (maxOffset < offsetMark) {
+            if (offsets[0] < offsetMark) {
               nullIterator = nullValueBitmap.peekableIterator();
             }
-            offsetMark = maxOffset;
+            offsetMark = offsets[offset.getCurrentVectorSize() - 1] + 1;
             encodedValueColumn.get(idVector, offsets, 
offset.getCurrentVectorSize());
           }
           for (int i = 0; i < offset.getCurrentVectorSize(); i++) {
@@ -847,9 +848,7 @@ public class VariantColumn<TStringDictionary extends 
Indexed<ByteBuffer>>
             }
           }
 
-          if (nullIterator != null) {
-            nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
-          }
+          nullVector = VectorSelectorUtils.populateNullVector(nullVector, 
offset, nullIterator);
 
           id = offset.getId();
         }
diff --git 
a/processing/src/main/java/org/roaringbitmap/buffer/DruidRoaringBufferAccess.java
 
b/processing/src/main/java/org/roaringbitmap/buffer/DruidRoaringBufferAccess.java
new file mode 100644
index 00000000000..b26753a5c4c
--- /dev/null
+++ 
b/processing/src/main/java/org/roaringbitmap/buffer/DruidRoaringBufferAccess.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+//CHECKSTYLE.OFF: PackageName - Must be in RoaringBitmap to reach 
ImmutableRoaringBitmap.highLowContainer
+
+package org.roaringbitmap.buffer;
+
+/**
+ * Exposes {@link ImmutableRoaringBitmap#highLowContainer}, which is 
package-private. Every method on
+ * {@link PointableRoaringArray} is public; only the field holding it is not.
+ */
+public final class DruidRoaringBufferAccess
+{
+  private DruidRoaringBufferAccess()
+  {
+  }
+
+  public static PointableRoaringArray highLowContainer(final 
ImmutableRoaringBitmap bitmap)
+  {
+    return bitmap.highLowContainer;
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIteratorTest.java
 
b/processing/src/test/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIteratorTest.java
new file mode 100644
index 00000000000..293bd42b16d
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/collections/bitmap/SeekableRoaringIntIteratorTest.java
@@ -0,0 +1,597 @@
+/*
+ * 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.collections.bitmap;
+
+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.IntIterator;
+import org.roaringbitmap.PeekableIntIterator;
+import org.roaringbitmap.buffer.DruidRoaringBufferAccess;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MappeableArrayContainer;
+import org.roaringbitmap.buffer.MappeableBitmapContainer;
+import org.roaringbitmap.buffer.MappeableRunContainer;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.roaringbitmap.buffer.PointableRoaringArray;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Random;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Checks {@link SeekableRoaringIntIterator} against {@link 
ImmutableRoaringBitmap#getIntIterator()}.
+ *
+ * <p>Bitmap values are unsigned, so this test carries them around as longs in 
{@code [0, 0xFFFFFFFF]} and uses
+ * {@link #NONE} for "the iterator is exhausted".
+ */
+public class SeekableRoaringIntIteratorTest
+{
+  /**
+   * Stands in for "no value", since every int is a legal bitmap value.
+   */
+  private static final long NONE = -1L;
+  private static final long UNSIGNED_LIMIT = 1L << 32;
+  private static final long RANDOM_SEED = 1234;
+
+  public static Stream<Arguments> bitmaps()
+  {
+    final List<Arguments> cases = new ArrayList<>();
+    final Random random = new Random(RANDOM_SEED);
+
+    // Sparse bitmaps. Should end up as array containers.
+    final MutableRoaringBitmap sparse = new MutableRoaringBitmap();
+    for (int i = 0; i < 3_000_000; i += 1 + random.nextInt(4000)) {
+      sparse.add(i);
+    }
+    cases.add(Arguments.of("sparse", toBufferBackedBitmap(sparse)));
+
+    // Dense bitmaps. Should end up as bitmap containers.
+    final MutableRoaringBitmap dense = new MutableRoaringBitmap();
+    for (int i = 0; i < 1_000_000; i++) {
+      if (random.nextDouble() < 0.6) {
+        dense.add(i);
+      }
+    }
+    cases.add(Arguments.of("dense", toBufferBackedBitmap(dense)));
+
+    // Runs, with whole containers missing in between. Should end up as run 
containers.
+    final MutableRoaringBitmap runs = new MutableRoaringBitmap();
+    runs.add(200_000L, 400_000L);
+    runs.add(2_000_000L, 2_300_000L);
+    runs.add(9_000_000L, 9_010_000L);
+    cases.add(Arguments.of("runs", toBufferBackedBitmap(runs)));
+
+    // A single value far from the origin.
+    final MutableRoaringBitmap lonely = new MutableRoaringBitmap();
+    lonely.add(15_000_000);
+    cases.add(Arguments.of("lonely", toBufferBackedBitmap(lonely)));
+
+    // Backed by a MutableRoaringArray rather than ByteBuffer, which is what
+    // WrappedRoaringBitmap.toImmutableBitmap() hands out.
+    final MutableRoaringBitmap heap = new MutableRoaringBitmap();
+    heap.add(50L, 70L);
+    heap.add(300_000L, 305_000L);
+    heap.add(5_000_000L, 5_000_100L);
+    heap.runOptimize();
+    cases.add(Arguments.of("heap", heap.toImmutableRoaringBitmap()));
+
+    // All three kinds of distributions in one bitmap.
+    final MutableRoaringBitmap mixed = new MutableRoaringBitmap();
+    for (int i = 0; i < 100; i++) {
+      mixed.add(i * 37);
+    }
+    for (int i = 0; i < 65536; i++) {
+      if (random.nextDouble() < 0.5) {
+        mixed.add(65536 + i);
+      }
+    }
+    mixed.add(2 * 65536L, 3 * 65536L);
+    mixed.add(4 * 65536 + 11);
+    mixed.add(4 * 65536 + 65535);
+    cases.add(Arguments.of("mixed", toBufferBackedBitmap(mixed)));
+
+    // Consecutive container keys.
+    final MutableRoaringBitmap adjacent = new MutableRoaringBitmap();
+    for (int key = 0; key < 10; key++) {
+      for (int i = 0; i < 20; i++) {
+        adjacent.add(key * 65536 + i * 3000);
+      }
+    }
+    cases.add(Arguments.of("adjacent", toBufferBackedBitmap(adjacent)));
+
+    // The first and last value of several containers, so seeks land exactly 
on container edges.
+    final MutableRoaringBitmap boundaries = new MutableRoaringBitmap();
+    for (int key = 0; key < 5; key++) {
+      boundaries.add(key * 65536);
+      boundaries.add(key * 65536 + 65535);
+    }
+    cases.add(Arguments.of("boundaries", toBufferBackedBitmap(boundaries)));
+
+    // Values above Integer.MAX_VALUE, where the container key has its high 
bit set and hs is negative.
+    final MutableRoaringBitmap high = new MutableRoaringBitmap();
+    high.add(1);
+    high.add(0x7FFFFFFF);
+    high.add(0x80000000);
+    high.add(0x80000001);
+    high.add(0xC0000000L, 0xC0010000L);
+    high.add(0xFFFF0000L, 0xFFFF0010L);
+    high.add(-1); // 0xFFFFFFFF, the largest unsigned value
+    cases.add(Arguments.of("high", toBufferBackedBitmap(high)));
+
+    return cases.stream();
+  }
+
+  @Test
+  public void testFixturesCoverEveryContainerKind()
+  {
+    // Verify we really do have all three kinds of containers in the test 
bitmaps.
+    final Set<String> kinds = new HashSet<>();
+    bitmaps().forEach(args -> {
+      final ImmutableRoaringBitmap bitmap = (ImmutableRoaringBitmap) 
args.get()[1];
+      final PointableRoaringArray containers = 
DruidRoaringBufferAccess.highLowContainer(bitmap);
+      for (int i = 0; i < containers.size(); i++) {
+        
kinds.add(containers.getContainerAtIndex(i).getClass().getSimpleName());
+      }
+    });
+
+    assertTrue(kinds.contains(MappeableArrayContainer.class.getSimpleName()), 
"array containers, got " + kinds);
+    assertTrue(kinds.contains(MappeableBitmapContainer.class.getSimpleName()), 
"bitmap containers, got " + kinds);
+    assertTrue(kinds.contains(MappeableRunContainer.class.getSimpleName()), 
"run containers, got " + kinds);
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testSequentialIteration(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final IntIterator reference = bitmap.getIntIterator();
+    long n = 0;
+    while (reference.hasNext()) {
+      assertTrue(iterator.hasNext(), "ran out early at " + n);
+      assertEquals(reference.next(), iterator.next(), "value " + n);
+      n++;
+    }
+    assertFalse(iterator.hasNext(), "extra values after " + n);
+    assertThrows(NoSuchElementException.class, iterator::next);
+    assertThrows(NoSuchElementException.class, iterator::peekNext);
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testPeekNextDoesNotAdvance(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final IntIterator reference = bitmap.getIntIterator();
+    while (reference.hasNext()) {
+      final int value = reference.next();
+      assertEquals(value, iterator.peekNext());
+      assertEquals(value, iterator.peekNext());
+      assertEquals(value, iterator.next());
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testRandomSeeksInBothDirections(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final Random random = new Random(5678);
+    final long span = span(bitmap);
+    for (int trial = 0; trial < 200_000; trial++) {
+      final int target = (int) random.nextLong(span);
+      iterator.seek(target);
+      assertEquals(expected(bitmap, target), peek(iterator), "seek(" + 
Integer.toUnsignedString(target) + ")");
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testAscendingSeeks(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final long span = span(bitmap);
+    final long step = Math.max(1, span / 20_000);
+    for (long target = 0; target < span; target += step) {
+      iterator.seek((int) target);
+      assertEquals(expected(bitmap, (int) target), peek(iterator), "seek(" + 
target + ")");
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testDescendingSeeks(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final long span = span(bitmap);
+    final long step = Math.max(1, span / 20_000);
+    for (long target = span - 1; target >= 0; target -= step) {
+      iterator.seek((int) target);
+      assertEquals(expected(bitmap, (int) target), peek(iterator), "seek(" + 
target + ")");
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testSeekIsIdempotent(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final Random random = new Random(2468);
+    final long span = span(bitmap);
+    for (int trial = 0; trial < 20_000; trial++) {
+      final int target = (int) random.nextLong(span);
+      iterator.seek(target);
+      final long first = peek(iterator);
+      iterator.seek(target);
+      assertEquals(first, peek(iterator), "second seek(" + 
Integer.toUnsignedString(target) + ")");
+      assertEquals(expected(bitmap, target), first, "seek(" + 
Integer.toUnsignedString(target) + ")");
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testInterleavedNextAndAdvanceIfNeeded(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final PeekableIntIterator reference = bitmap.getIntIterator();
+    final Random random = new Random(4321);
+    long cursor = 0;
+    while (reference.hasNext() && iterator.hasNext()) {
+      if (random.nextBoolean()) {
+        assertEquals(reference.next(), iterator.next(), "next at " + cursor);
+      } else {
+        // advanceIfNeeded is forward only, so never ask for less than where 
the iterator already is.
+        cursor = Math.max(cursor, peek(iterator)) + random.nextInt(20000);
+        if (cursor >= UNSIGNED_LIMIT) {
+          break;
+        }
+        reference.advanceIfNeeded((int) cursor);
+        iterator.advanceIfNeeded((int) cursor);
+        assertEquals(peek(reference), peek(iterator), "advanceIfNeeded(" + 
cursor + ")");
+      }
+    }
+    assertEquals(reference.hasNext(), iterator.hasNext());
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testRandomOperationsMatchReference(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final Random random = new Random(13579);
+    final long span = span(bitmap);
+    SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    PeekableIntIterator reference = bitmap.getIntIterator();
+
+    for (int op = 0; op < 100_000; op++) {
+      final int choice = random.nextInt(10);
+      final String what;
+      if (choice < 4) {
+        what = "next";
+        assertEquals(reference.hasNext(), iterator.hasNext(), "hasNext before 
" + what + " at op " + op);
+        if (reference.hasNext()) {
+          assertEquals(reference.next(), iterator.next(), "next at op " + op);
+        } else {
+          assertThrows(NoSuchElementException.class, iterator::next, "next at 
op " + op);
+        }
+      } else if (choice < 7) {
+        // Forward only, per the advanceIfNeeded contract. An exhausted 
iterator accepts anything, since it must
+        // stay exhausted either way.
+        final long from = Math.max(0, peek(iterator));
+        final long minval = from + random.nextLong(span - from);
+        what = "advanceIfNeeded(" + minval + ")";
+        reference.advanceIfNeeded((int) minval);
+        iterator.advanceIfNeeded((int) minval);
+      } else if (choice < 9) {
+        final long target = random.nextLong(span);
+        what = "seek(" + target + ")";
+        iterator.seek((int) target);
+        reference = bitmap.getIntIterator();
+        reference.advanceIfNeeded((int) target);
+      } else {
+        what = "clone";
+        iterator = iterator.clone();
+        reference = reference.clone();
+      }
+      assertEquals(peek(reference), peek(iterator), what + " at op " + op);
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testCloneIsIndependent(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator original = new 
SeekableRoaringIntIterator(bitmap);
+    for (int i = 0; i < 100 && original.hasNext(); i++) {
+      original.next();
+    }
+    final SeekableRoaringIntIterator copy = original.clone();
+    assertEquals(original.hasNext(), copy.hasNext());
+    while (original.hasNext()) {
+      assertTrue(copy.hasNext());
+      assertEquals(original.next(), copy.next());
+    }
+    assertFalse(copy.hasNext());
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testCloneDoesNotShareCursor(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator original = new 
SeekableRoaringIntIterator(bitmap);
+    final long first = peek(original);
+
+    // Draining the copy leaves the original untouched.
+    final SeekableRoaringIntIterator copy = original.clone();
+    while (copy.hasNext()) {
+      copy.next();
+    }
+    assertFalse(copy.hasNext());
+    assertEquals(first, peek(original));
+
+    // And seeking the original leaves an earlier copy untouched.
+    final SeekableRoaringIntIterator pinned = original.clone();
+    final int last = bitmap.last();
+    original.seek(last);
+    assertEquals(Integer.toUnsignedLong(last), peek(original));
+    assertEquals(first, peek(pinned));
+
+    // A clone of an exhausted iterator is exhausted, and can be brought back 
on its own.
+    original.next();
+    assertFalse(original.hasNext());
+    final SeekableRoaringIntIterator exhausted = original.clone();
+    assertFalse(exhausted.hasNext());
+    assertThrows(NoSuchElementException.class, exhausted::next);
+    exhausted.seek(0);
+    assertEquals(first, peek(exhausted));
+    assertFalse(original.hasNext());
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testAdvanceIfNeededDoesNotMoveBackwards(final String name, final 
ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final Random random = new Random(97531);
+
+    for (int trial = 0; trial < 20_000 && iterator.hasNext(); trial++) {
+      final long position = peek(iterator);
+      iterator.advanceIfNeeded((int) random.nextLong(position + 1));
+      assertEquals(position, peek(iterator), "backwards advanceIfNeeded from " 
+ position);
+      iterator.next();
+    }
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("bitmaps")
+  public void testAdvanceIfNeededOnExhaustedIteratorIsANoOp(final String name, 
final ImmutableRoaringBitmap bitmap)
+  {
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    final int first = iterator.peekNext();
+    while (iterator.hasNext()) {
+      iterator.next();
+    }
+
+    final long span = span(bitmap);
+    final Random random = new Random(11223);
+    for (int trial = 0; trial < 1000; trial++) {
+      iterator.advanceIfNeeded((int) random.nextLong(span));
+      assertFalse(iterator.hasNext(), "still exhausted");
+    }
+    iterator.advanceIfNeeded(0);
+    assertFalse(iterator.hasNext(), "still exhausted");
+
+    // seek() is not bound by the forward-only rule, so it does come back.
+    iterator.seek(0);
+    assertEquals(first, iterator.peekNext());
+  }
+
+  @Test
+  public void testAdvanceIntoGapLandsAtStartOfNextRange()
+  {
+    final MutableRoaringBitmap mutable = new MutableRoaringBitmap();
+    mutable.add(2_000_000L, 2_200_000L);
+    mutable.add(4_000_000L, 4_300_000L);
+    final ImmutableRoaringBitmap bitmap = toBufferBackedBitmap(mutable);
+
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+
+    assertEquals(2_000_000, iterator.next());
+
+    iterator.advanceIfNeeded(2_100_000);
+    assertEquals(2_100_000, iterator.next());
+
+    assertFalse(bitmap.contains(2_300_000));
+    iterator.advanceIfNeeded(2_300_000);
+    assertEquals(4_000_000, iterator.peekNext());
+
+    iterator.advanceIfNeeded(4_000_000);
+    assertEquals(4_000_000, iterator.next());
+  }
+
+  @Test
+  public void testSeekIntoWholeContainerGap()
+  {
+    // Targets in gaps that span whole containers, reached from both 
directions.
+    final MutableRoaringBitmap mutable = new MutableRoaringBitmap();
+    mutable.add(5);
+    mutable.add(10 * 65536 + 7);
+    mutable.add(50 * 65536 + 9);
+    final ImmutableRoaringBitmap bitmap = toBufferBackedBitmap(mutable);
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+
+    final int[] targets = {
+        0,
+        5,
+        6,
+        65536,
+        3 * 65536,
+        10 * 65536,
+        10 * 65536 + 7,
+        10 * 65536 + 8,
+        11 * 65536,
+        49 * 65536,
+        50 * 65536 + 9,
+        50 * 65536 + 10,
+        60 * 65536
+    };
+
+    for (final int target : targets) {
+      iterator.seek(target);
+      assertEquals(expected(bitmap, target), peek(iterator), "ascending seek(" 
+ target + ")");
+    }
+    for (int i = targets.length - 1; i >= 0; i--) {
+      iterator.seek(targets[i]);
+      assertEquals(expected(bitmap, targets[i]), peek(iterator), "descending 
seek(" + targets[i] + ")");
+    }
+  }
+
+  @Test
+  public void testEmptyBitmap()
+  {
+    final ImmutableRoaringBitmap bitmap = toBufferBackedBitmap(new 
MutableRoaringBitmap());
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+    assertFalse(iterator.hasNext());
+    iterator.seek(0);
+    assertFalse(iterator.hasNext());
+    iterator.seek(1_000_000);
+    assertFalse(iterator.hasNext());
+    iterator.advanceIfNeeded(0);
+    assertFalse(iterator.hasNext());
+    assertThrows(NoSuchElementException.class, iterator::next);
+    assertThrows(NoSuchElementException.class, iterator::peekNext);
+    assertFalse(iterator.clone().hasNext());
+  }
+
+  @Test
+  public void testSeekPastEndThenBack()
+  {
+    final MutableRoaringBitmap mutable = new MutableRoaringBitmap();
+    mutable.add(100L, 200L);
+    final ImmutableRoaringBitmap bitmap = toBufferBackedBitmap(mutable);
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+
+    iterator.seek(1_000_000);
+    assertFalse(iterator.hasNext());
+
+    iterator.seek(150);
+    assertEquals(150, iterator.peekNext());
+
+    iterator.seek(0);
+    assertEquals(100, iterator.peekNext());
+  }
+
+  @Test
+  public void testUnsignedValuesAboveIntegerMaxValue()
+  {
+    final MutableRoaringBitmap mutable = new MutableRoaringBitmap();
+    mutable.add(1);
+    mutable.add(0x7FFFFFFF);
+    mutable.add(0x80000000);
+    mutable.add(0xFFFFFFF0L, 0x100000000L);
+    final ImmutableRoaringBitmap bitmap = toBufferBackedBitmap(mutable);
+    final SeekableRoaringIntIterator iterator = new 
SeekableRoaringIntIterator(bitmap);
+
+    assertEquals(1, iterator.next());
+    assertEquals(0x7FFFFFFF, iterator.next());
+    assertEquals(0x80000000, iterator.next());
+    assertEquals(0xFFFFFFF0, iterator.next());
+
+    // Forwards across the sign boundary.
+    iterator.seek(0x7FFFFFFF);
+    assertEquals(0x7FFFFFFF, iterator.peekNext());
+    iterator.advanceIfNeeded(0x80000000);
+    assertEquals(0x80000000, iterator.peekNext());
+
+    // Backwards across it too, which advanceIfNeeded would refuse.
+    iterator.seek(0);
+    assertEquals(1, iterator.peekNext());
+
+    // The very top of the range.
+    iterator.seek(0xFFFFFFFF);
+    assertEquals(0xFFFFFFFF, iterator.peekNext());
+    assertEquals(0xFFFFFFFF, iterator.next());
+    assertFalse(iterator.hasNext());
+
+    // A gap between the sign boundary and the top containers.
+    iterator.seek(0x90000000);
+    assertEquals(0xFFFFFFF0, iterator.peekNext());
+  }
+
+  @Test
+  public void testWrappedImmutableRoaringBitmapHandsOutSeekableIterator()
+  {
+    final MutableRoaringBitmap mutable = new MutableRoaringBitmap();
+    mutable.add(1L, 100L);
+    final ImmutableBitmap wrapped = new 
WrappedImmutableRoaringBitmap(toBufferBackedBitmap(mutable));
+    assertInstanceOf(SeekableRoaringIntIterator.class, 
wrapped.peekableIterator());
+  }
+
+  /**
+   * Serializes through a ByteBuffer so the bitmap is backed by an 
ImmutableRoaringArray.
+   */
+  private static ImmutableRoaringBitmap toBufferBackedBitmap(final 
MutableRoaringBitmap mutable)
+  {
+    mutable.runOptimize();
+    final ByteBuffer buffer = 
ByteBuffer.allocate(mutable.serializedSizeInBytes())
+                                        .order(ByteOrder.LITTLE_ENDIAN);
+    mutable.serialize(buffer);
+    buffer.flip();
+    return new ImmutableRoaringBitmap(buffer);
+  }
+
+  /**
+   * Get the expected result for seeking an iterator to {@code target} and 
then peeking at the next value.
+   */
+  private static long expected(final ImmutableRoaringBitmap bitmap, final int 
target)
+  {
+    final PeekableIntIterator reference = bitmap.getIntIterator();
+    reference.advanceIfNeeded(target);
+    return peek(reference);
+  }
+
+  /**
+   * Peek at the next value, and return it as an unsigned int, or return 
{@link #NONE} if it is exhausted.
+   */
+  private static long peek(final PeekableIntIterator iterator)
+  {
+    return iterator.hasNext() ? Integer.toUnsignedLong(iterator.peekNext()) : 
NONE;
+  }
+
+  /**
+   * One past the highest value in the bitmap.
+   */
+  private static long span(final ImmutableRoaringBitmap bitmap)
+  {
+    return Integer.toUnsignedLong(bitmap.last()) + 1;
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/segment/filter/OrFilterVectorMatcherTest.java
 
b/processing/src/test/java/org/apache/druid/segment/filter/OrFilterVectorMatcherTest.java
new file mode 100644
index 00000000000..27f78e7acb8
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/filter/OrFilterVectorMatcherTest.java
@@ -0,0 +1,133 @@
+/*
+ * 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.filter;
+
+import org.apache.druid.collections.bitmap.MutableBitmap;
+import org.apache.druid.collections.bitmap.RoaringBitmapFactory;
+import org.apache.druid.query.filter.vector.ReadableVectorMatch;
+import org.apache.druid.query.filter.vector.VectorMatch;
+import org.apache.druid.query.filter.vector.VectorValueMatcher;
+import org.apache.druid.segment.vector.ReadableVectorOffset;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+public class OrFilterVectorMatcherTest
+{
+  private static final int[] INDEXED_ROWS = new int[]{3, 10, 13, 21};
+
+  public static Stream<Arguments> batches()
+  {
+    return Stream.of(
+        Arguments.of("ascending", new int[][]{{1, 2, 3}, {10, 11, 12}, {13, 
20}}, List.of(3, 10, 13)),
+        Arguments.of("descendingRanges", new int[][]{{20, 21}, {10, 11}, {1, 
3}}, List.of(21, 10, 3)),
+        Arguments.of("overlappingRanges", new int[][]{{1, 10}, {2, 3, 11}, 
{12, 13}}, List.of(10, 3, 13))
+    );
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("batches")
+  public void testMatchesIndexedRows(String name, int[][] batches, 
List<Integer> expected)
+  {
+    final MutableBitmap mutableBitmap = 
RoaringBitmapFactory.INSTANCE.makeEmptyMutableBitmap();
+    for (final int row : INDEXED_ROWS) {
+      mutableBitmap.add(row);
+    }
+
+    final BatchedVectorOffset offset = new BatchedVectorOffset(batches);
+    final VectorValueMatcher matcher = 
OrFilter.convertIndexToVectorValueMatcher(
+        offset,
+        RoaringBitmapFactory.INSTANCE.makeImmutableBitmap(mutableBitmap)
+    );
+
+    final List<Integer> matched = new ArrayList<>();
+    for (; offset.batchIndex < batches.length; offset.batchIndex++) {
+      final ReadableVectorMatch match = 
matcher.match(VectorMatch.allTrue(offset.getCurrentVectorSize()), false);
+      for (int i = 0; i < match.getSelectionSize(); i++) {
+        matched.add(offset.getOffsets()[match.getSelection()[i]]);
+      }
+    }
+
+    Assertions.assertEquals(expected, matched);
+  }
+
+  /**
+   * A {@link ReadableVectorOffset} that hands back a predetermined list of 
batches. Each batch must be internally
+   * ascending, but batches are otherwise unconstrained.
+   */
+  private static class BatchedVectorOffset implements ReadableVectorOffset
+  {
+    private final int[][] batches;
+    private final int maxVectorSize;
+
+    private int batchIndex = 0;
+
+    BatchedVectorOffset(final int[][] batches)
+    {
+      this.batches = batches;
+      int max = 0;
+      for (final int[] batch : batches) {
+        max = Math.max(max, batch.length);
+      }
+      this.maxVectorSize = max;
+    }
+
+    @Override
+    public boolean isContiguous()
+    {
+      return false;
+    }
+
+    @Override
+    public int getStartOffset()
+    {
+      throw new UnsupportedOperationException("not contiguous");
+    }
+
+    @Override
+    public int[] getOffsets()
+    {
+      return batches[batchIndex];
+    }
+
+    @Override
+    public int getId()
+    {
+      return batchIndex;
+    }
+
+    @Override
+    public int getMaxVectorSize()
+    {
+      return maxVectorSize;
+    }
+
+    @Override
+    public int getCurrentVectorSize()
+    {
+      return batches[batchIndex].length;
+    }
+  }
+}


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

Reply via email to