This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 3a36f139597 Optimize bitmap unions in query aggregation, star-tree
build, and JSON indexes (#19587)
3a36f139597 is described below
commit 3a36f139597d55a3951e9fb6d6814d1bb2ab7cfc
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Oct 7 17:04:20 2026 -0700
Optimize bitmap unions in query aggregation, star-tree build, and JSON
indexes (#19587)
* Union serialized bitmaps lazily in DISTINCT_COUNT_BITMAP aggregation
The serialized-bitmap (BYTES) aggregation paths - the ones hit when unioning
pre-aggregated bitmaps such as star-tree DISTINCT_COUNT_BITMAP columns -
fold
every matching document's bitmap into a per-group accumulator with
RoaringBitmap.or(), which recomputes container cardinality and re-normalizes
container types on every union. On a production-like workload dominated by
DISTINCT_COUNT_BITMAP over star-tree pre-aggregated bitmaps, JFR attributed
~85% of server execution samples to these unions.
Switch the fold to RoaringBitmap's lazy union (the primitive FastAggregation
uses internally): container cardinality maintenance is skipped during
accumulation, array containers promote to bitmap containers past 1024
combined
cardinality so subsequent unions of hot containers become word-wise ORs, and
the accumulator is repaired once at extraction. The new
RoaringBitmapLazyUnion
shim exposes the protected lazyor()/repairAfterLazy() primitives;
extractAggregationResult/extractGroupByResult repair the accumulator before
it
escapes the function, so results, intermediate types and wire format are
unchanged.
Fold benchmarks (RoaringBitmap 1.6.23): 2.2-2.7x faster with ~flat
allocation
for dense accumulators (small value universe), 4.7x faster for sparse
accumulators (at the cost of transient young-gen allocation from
pre-promotion
array copies), 5.4x faster for singleton-heavy input streams.
* Extend lazy bitmap unions to star-tree build, json index, and inverted
index count
- Move the RoaringBitmapLazyUnion shim from pinot-core to pinot-common and
add a
buffer-bitmap counterpart (MutableRoaringBitmapLazyUnion) so
pinot-segment-local
can use the same primitives.
- DistinctCountBitmapValueAggregator (star-tree build / segment
aggregation):
union serialized-bitmap raw values and aggregated values lazily,
repairing at
serializeAggregatedValue(). Inputs to applyAggregatedValue() are repaired
first
because on-heap star-tree records may themselves be lazy accumulators. The
per-row _maxByteSize tracking stays valid on a lazy accumulator:
serialized
container sizes do not depend on the deferred cardinality values, and a
lazy
bitmap container measures at its fixed 8KiB size, which upper-bounds the
size
after repair. Fold benchmarks: 2.2-6.2x faster.
- JSON index (ImmutableJsonIndexReader and MutableJsonIndexImpl): fold the
IN / NOT_IN / REGEXP_LIKE / RANGE posting-list unions through a lazy-union
accumulator that borrows single inputs without copying and repairs once
at the
end. Fold benchmarks: 5-11x faster.
- InvertedIndexFilterOperator: lazy fold for the >2-dictId union-cardinality
case (still materializes the union, but skips per-union cardinality
maintenance).
* Optimize sparse bitmap intermediate-result merges
* Use RoaringBitmapUnion accumulators for bitmap unions
Replace the three classes this branch shipped in the org.roaringbitmap
packages with RoaringBitmapUnion and MutableRoaringBitmapUnion in
org.apache.pinot.common.utils. They carry the names and methods of the
accumulators proposed to RoaringBitmap, so moving to the library classes
later is an import change plus one line in RoaringBitmapUtils. They reach
the protected lazy union through private subclasses, which removes the
split package and the use of package-private and deprecated API, and they
build against the released RoaringBitmap 1.6.23.
The released lazy union only pays off for dense containers and inserts
new container keys one at a time, so the interim classes union eagerly
while the accumulator is sparse or when an input brings many new keys.
Star-tree build: the bitmap aggregator reports the largest value it
serialized instead of measuring the accumulator on every apply. The
on-heap builder serializes BYTES metrics before it sizes the forward
indexes so that maximum is complete when it is read.
The sparse merge optimization is dropped from Pinot; it is part of the
RoaringBitmap change and merge() goes back to a plain or().
* Keep repeated sparse bitmap unions eager
* Avoid sparse union copies and unrelated StarTree serialization
* Trim unused bitmap union APIs and unrelated merge tests
---------
Co-authored-by: intentlab-ai[bot]
<intentlab-ai[bot]@users.noreply.github.com>
---
.../common/utils/MutableRoaringBitmapUnion.java | 182 ++++++++++
.../pinot/common/utils/RoaringBitmapUnion.java | 240 +++++++++++++
.../pinot/common/utils/RoaringBitmapUtils.java | 8 +
.../utils/MutableRoaringBitmapUnionTest.java | 299 +++++++++++++++
.../pinot/common/utils/RoaringBitmapUnionTest.java | 399 +++++++++++++++++++++
.../filter/InvertedIndexFilterOperator.java | 9 +-
.../DistinctCountBitmapAggregationFunction.java | 78 ++--
.../function/DistinctCountBitmapLazyUnionTest.java | 221 ++++++++++++
.../pinot/core/startree/v2/BaseStarTreeV2Test.java | 11 +-
.../v2/DistinctCountBitmapStarTreeV2Test.java | 18 +-
.../DistinctCountBitmapWithMVStarTreeV2Test.java | 17 +-
...a => DistinctCountHLLOnHeapStarTreeV2Test.java} | 32 +-
.../BenchmarkDistinctCountBitmapAggregation.java | 121 +++++++
.../DistinctCountBitmapValueAggregator.java | 60 ++--
.../segment/local/aggregator/ValueAggregator.java | 5 +
.../realtime/impl/json/MutableJsonIndexImpl.java | 67 ++--
.../readers/json/ImmutableJsonIndexReader.java | 59 ++-
.../startree/v2/builder/BaseSingleTreeBuilder.java | 17 +
.../v2/builder/OnHeapSingleTreeBuilder.java | 24 ++
.../DistinctCountBitmapValueAggregatorTest.java | 161 +++++++++
.../v2/builder/NullAwareStarTreeBuilderTest.java | 99 ++++-
21 files changed, 1993 insertions(+), 134 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnion.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnion.java
new file mode 100644
index 00000000000..8e41a931abb
--- /dev/null
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnion.java
@@ -0,0 +1,182 @@
+/**
+ * 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.pinot.common.utils;
+
+import java.util.Objects;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MappeableContainerPointer;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+
+
+/// Interim stand-in for `org.roaringbitmap.buffer.MutableRoaringBitmapUnion`,
the buffer counterpart of
+/// [RoaringBitmapUnion]. It exposes only the proposed methods used by Pinot,
so that the swap is an import change
+/// plus deleting this class. It is internal to Pinot and goes away with the
swap. See [RoaringBitmapUnion] for the
+/// contract and the swap steps.
+///
+/// Inputs may be read-only bitmaps over memory-mapped buffers; they are only
read.
+///
+/// Differences from the library class:
+///
+/// - The accumulated state is a private subclass of [MutableRoaringBitmap]
that reaches the protected lazy primitives
+/// through inheritance; [#get()] and [#take()] hand out instances of it.
+/// - An input is unioned eagerly while the accumulator holds few values per
container, when overlapping arrays would
+/// stay small, or when it would insert many container keys that are new to
the accumulator, because the released
+/// lazy union only pays off for dense containers and inserts new keys one
at a time.
+///
+/// Instances are not thread-safe.
+public final class MutableRoaringBitmapUnion {
+ // See RoaringBitmapUnion
+ private static final int MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION = 1024;
+ private static final int MAX_NEW_KEYS_FOR_LAZY_UNION = 4;
+
+ // The accumulated bitmap. Never null.
+ private LazyBitmap _bitmap;
+ // Whether the bitmap holds lazy state that must be repaired before it is
read
+ private boolean _dirty;
+ // Whether an alias returned by get() is outstanding and must not be mutated
+ private boolean _published;
+ // Number of values added so far, counting duplicates: an upper bound of the
cardinality that is known without
+ // repairing, used to estimate how dense the containers are
+ private long _numValuesAdded;
+ // A cardinality observed on repaired state. Unions never remove values, so
this remains a lower bound used to
+ // check average density without repairing every lazy union. Overlapping
arrays are checked separately.
+ private long _lastKnownCardinality;
+
+ /// Creates an empty union.
+ public MutableRoaringBitmapUnion() {
+ _bitmap = new LazyBitmap();
+ }
+
+ /// Unions the input into the accumulated state. The input is neither
modified nor retained. Adding this union's own
+ /// [#get()] result, or an empty bitmap, is a no-op.
+ public void add(ImmutableRoaringBitmap input) {
+ Objects.requireNonNull(input, "input");
+ if (input == _bitmap || input.isEmpty()) {
+ return;
+ }
+ beforeMutation();
+ boolean dense = isDenseForLazyUnion();
+ _numValuesAdded += input.getLongCardinality();
+ if (dense && !shouldUnionEagerly(input)) {
+ _dirty = true;
+ _bitmap.lazyOr(input);
+ } else {
+ repairIfDirty();
+ _bitmap.or(input);
+ }
+ }
+
+ /// Returns the accumulated bitmap with all pending lazy state repaired. The
returned instance is the union's
+ /// current state, not a copy; the union copies it before its next mutation,
so the returned bitmap stays valid.
+ /// Repeated calls without an intervening mutation return the same instance.
+ public MutableRoaringBitmap get() {
+ repairIfDirty();
+ _published = true;
+ return _bitmap;
+ }
+
+ /// Returns the accumulated bitmap, repaired as by [#get()], and transfers
its ownership to the caller. The union is
+ /// empty afterwards and can be reused.
+ public MutableRoaringBitmap take() {
+ repairIfDirty();
+ MutableRoaringBitmap result = _bitmap;
+ _bitmap = new LazyBitmap();
+ _dirty = false;
+ _published = false;
+ _numValuesAdded = 0;
+ _lastKnownCardinality = 0;
+ return result;
+ }
+
+ /// Returns whether the lazy union would copy small overlapping arrays or
insert more than a few new keys between
+ /// existing ones. Keys past the accumulator's last key do not count: they
are appended in one step.
+ private boolean shouldUnionEagerly(ImmutableRoaringBitmap input) {
+ // Called after the average density check: an empty or sole dense
container has no small-array overlaps.
+ // A small input cannot insert too many keys. Avoid allocating scan
pointers for this common case.
+ if (_bitmap.getContainerCount() <= 1 && input.getContainerCount() <=
MAX_NEW_KEYS_FOR_LAZY_UNION) {
+ return false;
+ }
+ MappeableContainerPointer own = _bitmap.getContainerPointer();
+ MappeableContainerPointer other = input.getContainerPointer();
+ int numNewKeys = 0;
+ while (other.hasContainer()) {
+ char key = other.key();
+ while (own.hasContainer() && own.key() < key) {
+ own.advance();
+ }
+ if (!own.hasContainer()) {
+ return false;
+ }
+ if (own.key() == key) {
+ if (!own.isBitmapContainer() && !own.isRunContainer() &&
!other.isBitmapContainer() && !other.isRunContainer()
+ && own.getCardinality() + other.getCardinality() <=
MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION) {
+ return true;
+ }
+ } else if (++numNewKeys > MAX_NEW_KEYS_FOR_LAZY_UNION) {
+ return true;
+ }
+ other.advance();
+ }
+ return false;
+ }
+
+ private boolean isDenseForLazyUnion() {
+ long minCardinality = (long) MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION *
_bitmap.getContainerCount();
+ if (_lastKnownCardinality >= minCardinality) {
+ return true;
+ }
+ if (_numValuesAdded < minCardinality) {
+ return false;
+ }
+ // The estimate counts duplicates. Confirm actual density before switching
a sparse accumulator to lazy unions.
+ repairIfDirty();
+ _lastKnownCardinality = _bitmap.getLongCardinality();
+ _numValuesAdded = _lastKnownCardinality;
+ return _lastKnownCardinality >= minCardinality;
+ }
+
+ private void repairIfDirty() {
+ if (_dirty) {
+ _bitmap.repair();
+ _dirty = false;
+ }
+ }
+
+ private void beforeMutation() {
+ if (_published) {
+ _bitmap = (LazyBitmap) _bitmap.clone();
+ _published = false;
+ }
+ }
+
+ /// Reaches the protected lazy primitives of [MutableRoaringBitmap] through
inheritance.
+ private static final class LazyBitmap extends MutableRoaringBitmap {
+ // Public so that an instance that escaped through get() or take() stays
deserializable like a plain bitmap
+ public LazyBitmap() {
+ }
+
+ void lazyOr(ImmutableRoaringBitmap other) {
+ lazyor(other);
+ }
+
+ void repair() {
+ repairAfterLazy();
+ }
+ }
+}
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUnion.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUnion.java
new file mode 100644
index 00000000000..2c7a05986ad
--- /dev/null
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUnion.java
@@ -0,0 +1,240 @@
+/**
+ * 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.pinot.common.utils;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import org.roaringbitmap.ContainerPointer;
+import org.roaringbitmap.RoaringBitmap;
+
+
+/// Interim stand-in for `org.roaringbitmap.RoaringBitmapUnion`, which is
proposed to RoaringBitmap for
+/// apache/pinot#19587 and not released yet. It exposes only the proposed
methods used by Pinot, so that once a
+/// RoaringBitmap release ships the class the swap is mechanical:
+///
+/// - change the import at the call sites to
`org.roaringbitmap.RoaringBitmapUnion`,
+/// - make [RoaringBitmapUtils#deserializeToUnion] call
`RoaringBitmapUnion.takeOwnership(deserialize(bytes))`,
+/// - delete this class, [MutableRoaringBitmapUnion] and their two tests (the
library tests its own classes).
+///
+/// `RoaringBitmapUnionTest` fails as soon as the library class is on the
classpath, as a reminder. This class is
+/// internal to Pinot and goes away with the swap; code outside this
repository should not depend on it.
+///
+/// An incremental union accumulator: it folds bitmaps that arrive over time
into one bitmap with RoaringBitmap's lazy
+/// union, which skips cardinality maintenance and keeps overlapping
containers in a form that makes further unions
+/// cheap, and it repairs the result only when it is read. Callers only ever
observe valid bitmaps:
+///
+/// - [#add(RoaringBitmap)] never modifies or retains its argument.
+/// - [#get()] returns the accumulated bitmap without copying. The union never
modifies that instance again: its next
+/// mutating call first copies it, so the returned bitmap stays valid.
Callers must treat it as read-only while they
+/// still intend to use the union.
+/// - [#take()] transfers ownership of the accumulated bitmap and leaves the
union empty.
+///
+/// Differences from the library class:
+///
+/// - The lazy primitives of [RoaringBitmap] are `protected`, so the
accumulated state is a private subclass that
+/// reaches them through inheritance. The bitmaps handed out by [#get()] and
[#take()] are instances of that
+/// subclass; it adds no state and overrides nothing.
+/// - Bitmaps are deserialized straight into a union with
[RoaringBitmapUtils#deserializeToUnion], and copies are
+/// made by adding to an empty union.
+/// - [#add(int)] repairs pending lazy state before inserting, because
inserting into a lazy container needs the
+/// library's internals, and it does not re-encode a run container that
received the value. A given accumulator
+/// receives either bitmaps or single values in Pinot, never both.
+/// - The released lazy union only pays off once containers are dense: while
they are small arrays it allocates a new
+/// container per union where the eager union merges in place, and it
inserts container keys that are new to the
+/// accumulator one at a time, which is quadratic when an input brings many
of them. So an input is unioned eagerly
+/// while the accumulator holds few values per container (hashed values
spread over many containers stay there for
+/// good), when overlapping arrays would stay small, or when the input would
insert many new keys, and lazily
+/// otherwise. The library class does not need this: its lazy union merges
small arrays in place and new keys in
+/// one pass.
+///
+/// Instances are not thread-safe.
+public final class RoaringBitmapUnion {
+ // The lazy union starts to pay off when containers hold more values than
the library's lazy array bound: past it
+ // they are kept as bitmaps whose unions skip cardinality maintenance, below
it they are arrays that the lazy union
+ // copies on every union
+ private static final int MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION = 1024;
+ // An input that would insert more new keys than this between existing ones
is unioned eagerly. Each insert shifts
+ // the accumulator's key and container arrays, and the eager union's single
merge pass costs about as much as a
+ // few of those shifts.
+ private static final int MAX_NEW_KEYS_FOR_LAZY_UNION = 4;
+
+ // The accumulated bitmap. Never null.
+ private LazyBitmap _bitmap;
+ // Whether the bitmap holds lazy state that must be repaired before it is
read
+ private boolean _dirty;
+ // Whether an alias returned by get() is outstanding and must not be mutated
+ private boolean _published;
+ // Number of values added so far, counting duplicates: an upper bound of the
cardinality that is known without
+ // repairing, used to estimate how dense the containers are
+ private long _numValuesAdded;
+ // A cardinality observed on repaired state. Unions never remove values, so
this remains a lower bound used to
+ // check average density without repairing every lazy union. Overlapping
arrays are checked separately.
+ private long _lastKnownCardinality;
+
+ /// Creates an empty union.
+ public RoaringBitmapUnion() {
+ _bitmap = new LazyBitmap();
+ }
+
+ private RoaringBitmapUnion(LazyBitmap adopted) {
+ _bitmap = adopted;
+ // Adopted state is normalized on the first read, like the library class
does
+ _dirty = true;
+ _numValuesAdded = adopted.getLongCardinality();
+ _lastKnownCardinality = _numValuesAdded;
+ }
+
+ /// Deserializes a bitmap straight into a union that owns it. Not part of
the library class: callers go through
+ /// [RoaringBitmapUtils#deserializeToUnion].
+ static RoaringBitmapUnion deserialize(ByteBuffer byteBuffer) {
+ LazyBitmap bitmap = new LazyBitmap();
+ try {
+ bitmap.deserialize(byteBuffer);
+ } catch (IOException e) {
+ throw new RuntimeException("Caught exception while deserializing
RoaringBitmap", e);
+ }
+ return new RoaringBitmapUnion(bitmap);
+ }
+
+ /// Unions the input into the accumulated state. The input is neither
modified nor retained, so it may be reused or
+ /// mutated afterwards. Adding this union's own [#get()] result, or an empty
bitmap, is a no-op.
+ public void add(RoaringBitmap input) {
+ Objects.requireNonNull(input, "input");
+ if (input == _bitmap || input.isEmpty()) {
+ return;
+ }
+ beforeMutation();
+ boolean dense = isDenseForLazyUnion();
+ _numValuesAdded += input.getLongCardinality();
+ if (dense && !shouldUnionEagerly(input)) {
+ _dirty = true;
+ _bitmap.lazyOr(input);
+ } else {
+ repairIfDirty();
+ _bitmap.or(input);
+ }
+ }
+
+ /// Unions a single value, treated as unsigned, into the accumulated state.
+ public void add(int value) {
+ beforeMutation();
+ repairIfDirty();
+ _bitmap.add(value);
+ _numValuesAdded++;
+ }
+
+ /// Returns the accumulated bitmap with all pending lazy state repaired. The
returned instance is the union's
+ /// current state, not a copy; the union copies it before its next mutation,
so the returned bitmap stays valid.
+ /// Repeated calls without an intervening mutation return the same instance.
+ public RoaringBitmap get() {
+ repairIfDirty();
+ _published = true;
+ return _bitmap;
+ }
+
+ /// Returns the accumulated bitmap, repaired as by [#get()], and transfers
its ownership to the caller. The union is
+ /// empty afterwards and can be reused.
+ public RoaringBitmap take() {
+ repairIfDirty();
+ RoaringBitmap result = _bitmap;
+ _bitmap = new LazyBitmap();
+ _dirty = false;
+ _published = false;
+ _numValuesAdded = 0;
+ _lastKnownCardinality = 0;
+ return result;
+ }
+
+ /// Returns whether the lazy union would copy small overlapping arrays or
insert more than a few new keys between
+ /// existing ones. Keys past the accumulator's last key do not count: they
are appended in one step.
+ private boolean shouldUnionEagerly(RoaringBitmap input) {
+ // Called after the average density check: an empty or sole dense
container has no small-array overlaps.
+ // A small input cannot insert too many keys. Avoid allocating scan
pointers for this common case.
+ if (_bitmap.getContainerCount() <= 1 && input.getContainerCount() <=
MAX_NEW_KEYS_FOR_LAZY_UNION) {
+ return false;
+ }
+ ContainerPointer own = _bitmap.getContainerPointer();
+ ContainerPointer other = input.getContainerPointer();
+ int numNewKeys = 0;
+ while (other.getContainer() != null) {
+ char key = other.key();
+ while (own.getContainer() != null && own.key() < key) {
+ own.advance();
+ }
+ if (own.getContainer() == null) {
+ return false;
+ }
+ if (own.key() == key) {
+ if (!own.isBitmapContainer() && !own.isRunContainer() &&
!other.isBitmapContainer() && !other.isRunContainer()
+ && own.getCardinality() + other.getCardinality() <=
MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION) {
+ return true;
+ }
+ } else if (++numNewKeys > MAX_NEW_KEYS_FOR_LAZY_UNION) {
+ return true;
+ }
+ other.advance();
+ }
+ return false;
+ }
+
+ private boolean isDenseForLazyUnion() {
+ long minCardinality = (long) MIN_VALUES_PER_CONTAINER_FOR_LAZY_UNION *
_bitmap.getContainerCount();
+ if (_lastKnownCardinality >= minCardinality) {
+ return true;
+ }
+ if (_numValuesAdded < minCardinality) {
+ return false;
+ }
+ // The estimate counts duplicates. Confirm actual density before switching
a sparse accumulator to lazy unions.
+ repairIfDirty();
+ _lastKnownCardinality = _bitmap.getLongCardinality();
+ _numValuesAdded = _lastKnownCardinality;
+ return _lastKnownCardinality >= minCardinality;
+ }
+
+ private void repairIfDirty() {
+ if (_dirty) {
+ _bitmap.repair();
+ _dirty = false;
+ }
+ }
+
+ private void beforeMutation() {
+ if (_published) {
+ _bitmap = (LazyBitmap) _bitmap.clone();
+ _published = false;
+ }
+ }
+
+ /// Reaches the protected lazy primitives of [RoaringBitmap] through
inheritance.
+ private static final class LazyBitmap extends RoaringBitmap {
+ // Public so that an instance that escaped through get() or take() stays
deserializable like a plain bitmap
+ public LazyBitmap() {
+ }
+
+ void lazyOr(RoaringBitmap other) {
+ lazyor(other);
+ }
+
+ void repair() {
+ repairAfterLazy();
+ }
+ }
+}
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUtils.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUtils.java
index 59b63760103..e37814d5d3a 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUtils.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/RoaringBitmapUtils.java
@@ -54,6 +54,14 @@ public class RoaringBitmapUtils {
return bitmap;
}
+ /// Deserializes a bitmap into a [RoaringBitmapUnion] that owns it, without
copying it.
+ ///
+ /// Once RoaringBitmap ships its own `RoaringBitmapUnion`, this becomes
+ /// `RoaringBitmapUnion.takeOwnership(deserialize(bytes))`.
+ public static RoaringBitmapUnion deserializeToUnion(byte[] bytes) {
+ return RoaringBitmapUnion.deserialize(ByteBuffer.wrap(bytes));
+ }
+
/// Iterates over the ranges of unset bits and calls the consumer for each
range. This is more performant to
/// alternatives like calling [RoaringBitmap#contains(int)] in a loop or
cloning and flipping the bitmap before
/// iterating, especially for sparse bitmaps.
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnionTest.java
b/pinot-common/src/test/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnionTest.java
new file mode 100644
index 00000000000..7f6f036fe3c
--- /dev/null
+++
b/pinot-common/src/test/java/org/apache/pinot/common/utils/MutableRoaringBitmapUnionTest.java
@@ -0,0 +1,299 @@
+/**
+ * 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.pinot.common.utils;
+
+import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.util.Random;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MappeableContainer;
+import org.roaringbitmap.buffer.MappeableContainerPointer;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+
+/// Covers the interim [MutableRoaringBitmapUnion] with the inputs Pinot feeds
it: read-only bitmaps over heap and
+/// direct buffers, as the index readers hand out, next to plain mutable
bitmaps.
+public class MutableRoaringBitmapUnionTest {
+ private static final long SEED = 20260929L;
+
+ private static MutableRoaringBitmap[] inputs(Random random, int numInputs) {
+ MutableRoaringBitmap[] inputs = new MutableRoaringBitmap[numInputs];
+ for (int i = 0; i < numInputs; i++) {
+ MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+ int base = random.nextInt(8) << 16;
+ switch (i % 3) {
+ case 0:
+ for (int j = 0; j < 300; j++) {
+ bitmap.add(base | random.nextInt(1 << 16));
+ }
+ break;
+ case 1:
+ bitmap.add((long) base + random.nextInt(1000), (long) base + 10000);
+ break;
+ default:
+ bitmap.add((long) base + random.nextInt(1000), (long) base + 6000);
+ bitmap.runOptimize();
+ break;
+ }
+ inputs[i] = bitmap;
+ }
+ return inputs;
+ }
+
+ /// The input as the readers present it: every third one over a read-only
heap buffer, every third one over a
+ /// read-only direct buffer, the rest as is.
+ private static ImmutableRoaringBitmap present(MutableRoaringBitmap bitmap,
int index) {
+ if (index % 3 == 0) {
+ return bitmap;
+ }
+ int size = bitmap.serializedSizeInBytes();
+ ByteBuffer buffer = index % 3 == 1 ? ByteBuffer.allocate(size) :
ByteBuffer.allocateDirect(size);
+ bitmap.serialize(buffer);
+ buffer.flip();
+ return new ImmutableRoaringBitmap(buffer.asReadOnlyBuffer());
+ }
+
+ private static MutableRoaringBitmap eagerUnion(MutableRoaringBitmap[]
inputs, int numInputs) {
+ MutableRoaringBitmap expected = new MutableRoaringBitmap();
+ for (int i = 0; i < numInputs; i++) {
+ expected.or(inputs[i]);
+ }
+ return expected;
+ }
+
+ private static void assertValid(MutableRoaringBitmap actual,
MutableRoaringBitmap expected) {
+ // A bitmap left in lazy state reports wrong cardinalities and fails
validation
+ assertTrue(actual.validate());
+ assertTrue(actual.equals(expected));
+ assertEquals(actual.getLongCardinality(), expected.getLongCardinality());
+ ByteBuffer buffer = ByteBuffer.allocate(actual.serializedSizeInBytes());
+ actual.serialize(buffer);
+ buffer.flip();
+ assertTrue(new ImmutableRoaringBitmap(buffer).equals(expected));
+ }
+
+ @Test
+ public void testFoldOverMappedInputsMatchesEagerUnion() {
+ Random random = new Random(SEED);
+ MutableRoaringBitmap[] inputs = inputs(random, 60);
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ for (int i = 0; i < inputs.length; i++) {
+ MutableRoaringBitmap copy = inputs[i].clone();
+ ImmutableRoaringBitmap presented = present(inputs[i], i);
+ union.add(presented);
+ if (i % 7 == 0) {
+ union.add(presented);
+ union.add(new MutableRoaringBitmap());
+ union.add(union.get());
+ }
+ assertTrue(presented.equals(copy));
+ if (i % 10 == 9) {
+ assertValid(union.get(), eagerUnion(inputs, i + 1));
+ }
+ }
+ assertValid(union.take(), eagerUnion(inputs, inputs.length));
+ }
+
+ @Test
+ public void testGetIsStableAndTakeResets() {
+ Random random = new Random(SEED + 1);
+ MutableRoaringBitmap[] inputs = inputs(random, 20);
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ for (int i = 0; i < 10; i++) {
+ union.add(present(inputs[i], i));
+ }
+ MutableRoaringBitmap published = union.get();
+ assertSame(union.get(), published);
+ MutableRoaringBitmap snapshot = published.clone();
+ for (int i = 10; i < 20; i++) {
+ union.add(present(inputs[i], i));
+ }
+ assertValid(published, snapshot);
+
+ MutableRoaringBitmap expected = eagerUnion(inputs, 20);
+ MutableRoaringBitmap taken = union.take();
+ assertNotSame(taken, published);
+ assertValid(taken, expected);
+ assertTrue(union.take().isEmpty());
+ }
+
+ @Test
+ public void testMappedInputsWithManyNewKeys() {
+ Random random = new Random(SEED + 3);
+ MutableRoaringBitmap[] dense = inputs(random, 30);
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ MutableRoaringBitmap expected = new MutableRoaringBitmap();
+ for (int i = 0; i < dense.length; i++) {
+ // Hashed values: every input brings many container keys the accumulator
does not have yet
+ MutableRoaringBitmap hashed = new MutableRoaringBitmap();
+ for (int j = 0; j < 500; j++) {
+ hashed.add(random.nextInt());
+ }
+ union.add(present(dense[i], i));
+ union.add(present(hashed, i + 1));
+ expected.or(dense[i]);
+ expected.or(hashed);
+ if (i % 8 == 7) {
+ assertValid(union.get(), expected);
+ }
+ }
+ assertValid(union.take(), expected);
+ }
+
+ @Test
+ public void testSparseMappedInputsInterleavedWithDenseInputs() {
+ Random random = new Random(SEED + 4);
+ MutableRoaringBitmap[] dense = inputs(random, 80);
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ MutableRoaringBitmap expected = new MutableRoaringBitmap();
+ for (int i = 0; i < dense.length; i++) {
+ union.add(present(dense[i], i));
+ expected.or(dense[i]);
+ if (i % 8 == 7) {
+ // A few values spread over new keys, added to an accumulator that
mostly holds dense containers
+ MutableRoaringBitmap sparse = new MutableRoaringBitmap();
+ for (int j = 0; j < 24; j++) {
+ sparse.add(random.nextInt());
+ }
+ union.add(present(sparse, i));
+ expected.or(sparse);
+ }
+ if (i % 16 == 15) {
+ assertValid(union.get(), expected);
+ }
+ }
+ assertValid(union.take(), expected);
+ }
+
+ // Inspect container identity without get(), whose publication would
legitimately trigger a copy on the next add.
+ private static MutableRoaringBitmap
accumulatedBitmap(MutableRoaringBitmapUnion union)
+ throws ReflectiveOperationException {
+ Field field = MutableRoaringBitmapUnion.class.getDeclaredField("_bitmap");
+ field.setAccessible(true);
+ return (MutableRoaringBitmap) field.get(union);
+ }
+
+ @Test
+ public void testRepeatedSparseMappedInputsStayInPlace()
+ throws ReflectiveOperationException {
+ MutableRoaringBitmap input = new MutableRoaringBitmap();
+ for (int key = 0; key < 24; key++) {
+ input.add(key << 16);
+ }
+ for (int presentation = 1; presentation <= 2; presentation++) {
+ ImmutableRoaringBitmap mapped = present(input, presentation);
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ union.add(mapped);
+ MutableRoaringBitmap owned = accumulatedBitmap(union);
+ MappeableContainer[] containers = new
MappeableContainer[owned.getContainerCount()];
+ MappeableContainerPointer pointer = owned.getContainerPointer();
+ for (int i = 0; i < containers.length; i++) {
+ containers[i] = pointer.getContainer();
+ pointer.advance();
+ }
+ // Exercise read-only heap and direct buffers without publishing
intermediate accumulator state.
+ for (int i = 0; i < 2048; i++) {
+ union.add(mapped);
+ }
+ MutableRoaringBitmap result = union.take();
+ assertValid(result, input);
+ assertTrue(mapped.equals(input));
+ pointer = result.getContainerPointer();
+ for (MappeableContainer container : containers) {
+ assertSame(pointer.getContainer(), container);
+ pointer.advance();
+ }
+ }
+ }
+
+ @Test
+ public void testSparseMappedContainersStayInPlaceWithSkewedDensity()
+ throws ReflectiveOperationException {
+ for (int numSparseKeys : new int[]{1, 50}) {
+ MutableRoaringBitmap initial = new MutableRoaringBitmap();
+ initial.add(0L, 1L << 16);
+ for (int key = 1; key <= numSparseKeys; key++) {
+ initial.add(key << 16);
+ }
+ for (int presentation = 1; presentation <= 2; presentation++) {
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ union.add(present(initial, presentation));
+ MutableRoaringBitmap owned = accumulatedBitmap(union);
+ MappeableContainerPointer pointer = owned.getContainerPointer();
+ pointer.advance();
+ MappeableContainer[] sparseContainers = new
MappeableContainer[numSparseKeys];
+ for (int i = 0; i < numSparseKeys; i++) {
+ sparseContainers[i] = pointer.getContainer();
+ pointer.advance();
+ }
+ MutableRoaringBitmap expected = initial.clone();
+ // The full first container masks the sparse arrays in the average
density.
+ for (int i = 1; i <= 500; i++) {
+ MutableRoaringBitmap input = new MutableRoaringBitmap();
+ for (int key = 1; key <= numSparseKeys; key++) {
+ input.add((key << 16) | (i * 2));
+ }
+ ImmutableRoaringBitmap mapped = present(input, presentation);
+ union.add(mapped);
+ expected.or(input);
+ assertTrue(mapped.equals(input));
+ }
+ MutableRoaringBitmap result = union.take();
+ assertValid(result, expected);
+ pointer = result.getContainerPointer();
+ pointer.advance();
+ for (MappeableContainer container : sparseContainers) {
+ assertSame(pointer.getContainer(), container);
+ pointer.advance();
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testDenseMappedInputsStillUseLazyUnion()
+ throws ReflectiveOperationException {
+ MutableRoaringBitmap input = new MutableRoaringBitmap();
+ for (int value = 0; value < 1024; value++) {
+ input.add(value);
+ }
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
+ union.add(input);
+ MutableRoaringBitmap owned = accumulatedBitmap(union);
+ MappeableContainer container = owned.getContainerPointer().getContainer();
+ MutableRoaringBitmap duplicate = new MutableRoaringBitmap();
+ duplicate.add(0);
+ union.add(present(duplicate, 2));
+ MutableRoaringBitmap result = union.take();
+ assertValid(result, input);
+ assertNotSame(result.getContainerPointer().getContainer(), container);
+ }
+
+ @Test
+ public void testNullArguments() {
+ assertThrows(NullPointerException.class, () -> new
MutableRoaringBitmapUnion().add(null));
+ }
+}
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/utils/RoaringBitmapUnionTest.java
b/pinot-common/src/test/java/org/apache/pinot/common/utils/RoaringBitmapUnionTest.java
new file mode 100644
index 00000000000..95f9643a8cf
--- /dev/null
+++
b/pinot-common/src/test/java/org/apache/pinot/common/utils/RoaringBitmapUnionTest.java
@@ -0,0 +1,399 @@
+/**
+ * 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.pinot.common.utils;
+
+import java.lang.reflect.Field;
+import java.util.Random;
+import org.roaringbitmap.Container;
+import org.roaringbitmap.ContainerPointer;
+import org.roaringbitmap.RoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+
+/// Covers the interim [RoaringBitmapUnion]: folds are compared with eager
unions, and the ownership rules of
+/// `get()` and `take()` are pinned so that the swap to the library class
cannot change behavior unnoticed.
+public class RoaringBitmapUnionTest {
+ private static final long SEED = 20260929L;
+
+ /// Inputs mixing sparse values, dense ranges and run-optimized ranges, so
that array, bitmap and run containers
+ /// all take part, including keys in the upper (unsigned) half of the range.
+ private static RoaringBitmap[] inputs(Random random, int numInputs) {
+ RoaringBitmap[] inputs = new RoaringBitmap[numInputs];
+ for (int i = 0; i < numInputs; i++) {
+ RoaringBitmap bitmap = new RoaringBitmap();
+ int base = random.nextInt(8) << 16;
+ if (random.nextBoolean()) {
+ base |= 0x80000000;
+ }
+ switch (i % 3) {
+ case 0:
+ for (int j = 0; j < 300; j++) {
+ bitmap.add(base | random.nextInt(1 << 16));
+ }
+ break;
+ case 1:
+ long start = (base & 0xFFFFFFFFL) + random.nextInt(1000);
+ bitmap.add(start, start + 9000);
+ break;
+ default:
+ long runStart = (base & 0xFFFFFFFFL) + random.nextInt(1000);
+ bitmap.add(runStart, runStart + 5000);
+ bitmap.runOptimize();
+ break;
+ }
+ inputs[i] = bitmap;
+ }
+ return inputs;
+ }
+
+ private static RoaringBitmap eagerUnion(RoaringBitmap[] inputs, int
numInputs) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < numInputs; i++) {
+ expected.or(inputs[i]);
+ }
+ return expected;
+ }
+
+ private static void assertValid(RoaringBitmap actual, RoaringBitmap
expected) {
+ // A bitmap left in lazy state reports wrong cardinalities and fails
validation
+ assertTrue(actual.validate());
+ assertTrue(actual.equals(expected));
+ assertEquals(actual.getLongCardinality(), expected.getLongCardinality());
+
assertTrue(RoaringBitmapUtils.deserialize(RoaringBitmapUtils.serialize(actual)).equals(expected));
+ }
+
+ /// Bitmaps of hashed values: a few hundred values spread over the whole
unsigned range, so that every input brings
+ /// many container keys the accumulator does not have yet.
+ private static RoaringBitmap[] hashedInputs(Random random, int numInputs,
int valuesPerInput) {
+ RoaringBitmap[] inputs = new RoaringBitmap[numInputs];
+ for (int i = 0; i < numInputs; i++) {
+ RoaringBitmap bitmap = new RoaringBitmap();
+ for (int j = 0; j < valuesPerInput; j++) {
+ bitmap.add(random.nextInt());
+ }
+ inputs[i] = bitmap;
+ }
+ return inputs;
+ }
+
+ @Test
+ public void testFoldMatchesEagerUnionAndLeavesInputsUntouched() {
+ Random random = new Random(SEED);
+ RoaringBitmap[] inputs = inputs(random, 60);
+ RoaringBitmap[] copies = new RoaringBitmap[inputs.length];
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ for (int i = 0; i < inputs.length; i++) {
+ copies[i] = inputs[i].clone();
+ union.add(inputs[i]);
+ // The same input twice, an empty input and the union's own result never
change the outcome
+ if (i % 7 == 0) {
+ union.add(inputs[i]);
+ union.add(new RoaringBitmap());
+ union.add(union.get());
+ }
+ if (i % 10 == 9) {
+ assertValid(union.get(), eagerUnion(inputs, i + 1));
+ }
+ }
+ assertValid(union.take(), eagerUnion(inputs, inputs.length));
+ for (int i = 0; i < inputs.length; i++) {
+ assertTrue(inputs[i].equals(copies[i]));
+ }
+ }
+
+ @Test
+ public void testGetIsStableAndNeverInvalidatedByLaterAdds() {
+ Random random = new Random(SEED + 1);
+ RoaringBitmap[] inputs = inputs(random, 20);
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ for (int i = 0; i < 10; i++) {
+ union.add(inputs[i]);
+ }
+ RoaringBitmap published = union.get();
+ assertSame(union.get(), published);
+ RoaringBitmap snapshot = published.clone();
+
+ for (int i = 10; i < 20; i++) {
+ union.add(inputs[i]);
+ }
+ union.add(12345);
+ // The published bitmap was copied before the union changed again
+ assertValid(published, snapshot);
+ RoaringBitmap expected = eagerUnion(inputs, 20);
+ expected.add(12345);
+ RoaringBitmap latest = union.get();
+ assertNotSame(latest, published);
+ assertValid(latest, expected);
+ }
+
+ @Test
+ public void testTakeTransfersOwnershipAndResets() {
+ Random random = new Random(SEED + 2);
+ RoaringBitmap[] inputs = inputs(random, 10);
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ for (RoaringBitmap input : inputs) {
+ union.add(input);
+ }
+ RoaringBitmap published = union.get();
+ RoaringBitmap taken = union.take();
+ assertSame(taken, published);
+ RoaringBitmap snapshot = taken.clone();
+
+ assertTrue(union.get().isEmpty());
+ union.add(inputs[0]);
+ union.add(7);
+ assertValid(taken, snapshot);
+ RoaringBitmap expected = inputs[0].clone();
+ expected.add(7);
+ assertValid(union.take(), expected);
+ assertTrue(union.take().isEmpty());
+ }
+
+ @Test
+ public void testDeserializeToUnionKeepsAccumulating() {
+ Random random = new Random(SEED + 3);
+ RoaringBitmap[] inputs = inputs(random, 12);
+ RoaringBitmapUnion union =
RoaringBitmapUtils.deserializeToUnion(RoaringBitmapUtils.serialize(inputs[0]));
+ assertValid(union.get(), inputs[0]);
+ for (int i = 1; i < inputs.length; i++) {
+ union.add(inputs[i]);
+ }
+ RoaringBitmap result = union.take();
+ assertValid(result, eagerUnion(inputs, inputs.length));
+ }
+
+ @Test
+ public void testInputsWithManyNewKeysInterleavedWithDenseInputs() {
+ Random random = new Random(SEED + 5);
+ RoaringBitmap[] hashed = hashedInputs(random, 40, 500);
+ RoaringBitmap[] dense = inputs(random, 40);
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < hashed.length; i++) {
+ RoaringBitmap hashedCopy = hashed[i].clone();
+ // Inputs that bring many new keys and inputs that bring few alternate,
so pending lazy state has to be
+ // repaired before the accumulator is unioned eagerly
+ union.add(dense[i]);
+ union.add(hashed[i]);
+ expected.or(dense[i]);
+ expected.or(hashed[i]);
+ assertTrue(hashed[i].equals(hashedCopy));
+ if (i % 8 == 7) {
+ assertValid(union.get(), expected);
+ }
+ }
+ // Once the accumulator has most keys, the same inputs bring few new ones
+ for (RoaringBitmap input : hashed) {
+ union.add(input);
+ }
+ assertValid(union.take(), expected);
+ }
+
+ @Test
+ public void testSparseInputsInterleavedWithDenseInputs() {
+ Random random = new Random(SEED + 6);
+ RoaringBitmap[] dense = inputs(random, 80);
+ RoaringBitmap[] sparse = hashedInputs(random, 10, 24);
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < dense.length; i++) {
+ union.add(dense[i]);
+ expected.or(dense[i]);
+ if (i % 8 == 7) {
+ // A few values spread over new keys, added to an accumulator that
mostly holds dense containers
+ union.add(sparse[i / 8]);
+ expected.or(sparse[i / 8]);
+ }
+ if (i % 16 == 15) {
+ assertValid(union.get(), expected);
+ }
+ }
+ // Enough hashed values to leave the accumulator with mostly sparse
containers, then dense inputs again
+ for (RoaringBitmap input : hashedInputs(random, 40, 500)) {
+ union.add(input);
+ expected.or(input);
+ }
+ assertValid(union.get(), expected);
+ for (RoaringBitmap input : inputs(random, 20)) {
+ union.add(input);
+ expected.or(input);
+ }
+ assertValid(union.take(), expected);
+ }
+
+ @Test
+ public void testSingleValuesMixedWithBitmaps() {
+ Random random = new Random(SEED + 4);
+ RoaringBitmap[] inputs = inputs(random, 30);
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < inputs.length; i++) {
+ union.add(inputs[i]);
+ expected.or(inputs[i]);
+ // Single values landing in containers the previous lazy union just
touched, and in new ones
+ for (int j = 0; j < 5; j++) {
+ int value = random.nextBoolean() ? inputs[i].first() + j :
random.nextInt();
+ union.add(value);
+ expected.add(value);
+ }
+ }
+ union.add(-1);
+ expected.add(-1);
+ assertValid(union.take(), expected);
+ }
+
+ @Test
+ public void testRawValuesOnlyCrossTheArrayToBitmapThreshold() {
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < 5000; i++) {
+ union.add(3 * i);
+ expected.add(3 * i);
+ }
+ assertValid(union.get(), expected);
+ }
+
+ // Inspect container identity without get(), whose publication would
legitimately trigger a copy on the next add.
+ private static RoaringBitmap accumulatedBitmap(RoaringBitmapUnion union)
+ throws ReflectiveOperationException {
+ Field field = RoaringBitmapUnion.class.getDeclaredField("_bitmap");
+ field.setAccessible(true);
+ return (RoaringBitmap) field.get(union);
+ }
+
+ @Test
+ public void testRepeatedSparseInputsStayInPlace()
+ throws ReflectiveOperationException {
+ RoaringBitmap input = new RoaringBitmap();
+ for (int key = 0; key < 24; key++) {
+ input.add(key << 16);
+ }
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ union.add(input);
+ RoaringBitmap owned = accumulatedBitmap(union);
+ Container[] containers = new Container[owned.getContainerCount()];
+ ContainerPointer pointer = owned.getContainerPointer();
+ for (int i = 0; i < containers.length; i++) {
+ containers[i] = pointer.getContainer();
+ pointer.advance();
+ }
+ // Duplicate counts exceed the lazy threshold, but the result remains one
value per container.
+ for (int i = 0; i < 2048; i++) {
+ union.add(input);
+ }
+ RoaringBitmap result = union.take();
+ assertValid(result, input);
+ pointer = result.getContainerPointer();
+ for (Container container : containers) {
+ // Eager unions reuse these sparse arrays; lazy unions replace them even
when no values change.
+ assertSame(pointer.getContainer(), container);
+ pointer.advance();
+ }
+ }
+
+ @Test
+ public void testSparseContainersStayInPlaceWithSkewedDensity()
+ throws ReflectiveOperationException {
+ for (int numSparseKeys : new int[]{1, 50}) {
+ RoaringBitmap initial = new RoaringBitmap();
+ initial.add(0L, 1L << 16);
+ for (int key = 1; key <= numSparseKeys; key++) {
+ initial.add(key << 16);
+ }
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ union.add(initial);
+ RoaringBitmap owned = accumulatedBitmap(union);
+ ContainerPointer pointer = owned.getContainerPointer();
+ pointer.advance();
+ Container[] sparseContainers = new Container[numSparseKeys];
+ for (int i = 0; i < numSparseKeys; i++) {
+ sparseContainers[i] = pointer.getContainer();
+ pointer.advance();
+ }
+ RoaringBitmap expected = initial.clone();
+ // The full first container masks the sparse arrays in the average
density, even with 50 sparse keys.
+ for (int i = 1; i <= 500; i++) {
+ RoaringBitmap input = new RoaringBitmap();
+ for (int key = 1; key <= numSparseKeys; key++) {
+ input.add((key << 16) | (i * 2));
+ }
+ union.add(input);
+ expected.or(input);
+ }
+ RoaringBitmap result = union.take();
+ assertValid(result, expected);
+ pointer = result.getContainerPointer();
+ pointer.advance();
+ for (Container container : sparseContainers) {
+ // Eager unions grow these arrays in place; lazy unions allocate a
replacement on every input.
+ assertSame(pointer.getContainer(), container);
+ pointer.advance();
+ }
+ }
+ }
+
+ @Test
+ public void testDenseInputsStillUseLazyUnion()
+ throws ReflectiveOperationException {
+ RoaringBitmap input = new RoaringBitmap();
+ for (int value = 0; value < 1024; value++) {
+ input.add(value);
+ }
+ RoaringBitmapUnion union = new RoaringBitmapUnion();
+ union.add(input);
+ RoaringBitmap owned = accumulatedBitmap(union);
+ Container container = owned.getContainerPointer().getContainer();
+ union.add(RoaringBitmap.bitmapOf(0));
+ RoaringBitmap result = union.take();
+ assertValid(result, input);
+ // Lazy union promotes the dense array and repair replaces it; eager union
would keep the array.
+ assertNotSame(result.getContainerPointer().getContainer(), container);
+ }
+
+ @Test
+ public void testNullArguments() {
+ assertThrows(NullPointerException.class, () -> new
RoaringBitmapUnion().add((RoaringBitmap) null));
+ }
+
+ /// Fails once the RoaringBitmap library on the classpath ships the classes
these interim ones stand in for, with
+ /// the steps of the swap in the failure message.
+ @Test
+ public void testLibraryDoesNotShipTheUnionClassesYet() {
+ for (String libraryClass : new String[]{
+ "org.roaringbitmap.RoaringBitmapUnion",
"org.roaringbitmap.buffer.MutableRoaringBitmapUnion"
+ }) {
+ try {
+ Class.forName(libraryClass);
+ } catch (ClassNotFoundException e) {
+ continue;
+ }
+ fail("RoaringBitmap now ships " + libraryClass + ". Replace Pinot's
interim copies: import the union classes "
+ + "from org.roaringbitmap and org.roaringbitmap.buffer at the call
sites, make "
+ + "RoaringBitmapUtils.deserializeToUnion call
RoaringBitmapUnion.takeOwnership(deserialize(bytes)), and "
+ + "delete org.apache.pinot.common.utils.RoaringBitmapUnion,
MutableRoaringBitmapUnion and their two tests");
+ }
+ }
+}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
index f4555a0b408..2a83176e2eb 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
@@ -21,6 +21,7 @@ package org.apache.pinot.core.operator.filter;
import com.google.common.base.CaseFormat;
import java.util.List;
import org.apache.pinot.common.request.context.predicate.Predicate;
+import org.apache.pinot.common.utils.MutableRoaringBitmapUnion;
import org.apache.pinot.core.common.BlockDocIdSet;
import org.apache.pinot.core.common.Operator;
import org.apache.pinot.core.operator.ExplainAttributeBuilder;
@@ -145,11 +146,13 @@ public class InvertedIndexFilterOperator extends
BaseColumnFilterOperator {
_invertedIndexReader.getDocIds(dictIds[1]));
break;
default:
- MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+ // Union lazily and finalize once at the end: the fold still
materializes the union, but skips per-union
+ // cardinality maintenance
+ MutableRoaringBitmapUnion union = new MutableRoaringBitmapUnion();
for (int dictId : dictIds) {
- bitmap.or(_invertedIndexReader.getDocIds(dictId));
+ union.add(_invertedIndexReader.getDocIds(dictId));
}
- count = bitmap.getCardinality();
+ count = union.get().getCardinality();
break;
}
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
index 25fb0ea5029..160fd83d5e9 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
@@ -25,6 +25,7 @@ import javax.annotation.Nullable;
import org.apache.pinot.common.CustomObject;
import org.apache.pinot.common.request.context.ExpressionContext;
import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
import org.apache.pinot.common.utils.RoaringBitmapUtils;
import org.apache.pinot.core.common.BlockValSet;
import org.apache.pinot.core.common.ObjectSerDeUtils;
@@ -78,19 +79,14 @@ public class DistinctCountBitmapAggregationFunction extends
BaseSingleInputAggre
// Logical BYTES is a serialized RoaringBitmap and always uses the
single-value representation.
byte[][] bytesValues = blockValSet.getBytesValuesSV();
forEachNotNull(length, blockValSet, (from, to) -> {
- int i = from;
- RoaringBitmap valueBitmap = aggregationResultHolder.getResult();
- if (valueBitmap == null) {
- if (i == to) {
- return;
- }
- // The first bitmap read becomes the accumulator instead of being
merged into a fresh one
- valueBitmap = RoaringBitmapUtils.deserialize(bytesValues[i++]);
- aggregationResultHolder.setValue(valueBitmap);
- }
- for (; i < to; i++) {
- valueBitmap.or(RoaringBitmapUtils.deserialize(bytesValues[i]));
+ Object result = aggregationResultHolder.getResult();
+ for (int i = from; i < to; i++) {
+ // The first bitmap read is deserialized straight into the
accumulator instead of being merged into a fresh
+ // one; later ones are unioned lazily, and
extractAggregationResult() finalizes the accumulator exactly once
+ result = result == null ?
RoaringBitmapUtils.deserializeToUnion(bytesValues[i])
+ : addBitmap(result,
RoaringBitmapUtils.deserialize(bytesValues[i]));
}
+ aggregationResultHolder.setValue(result);
});
return;
}
@@ -271,14 +267,11 @@ public class DistinctCountBitmapAggregationFunction
extends BaseSingleInputAggre
byte[][] bytesValues = blockValSet.getBytesValuesSV();
forEachNotNull(length, blockValSet, (from, to) -> {
for (int i = from; i < to; i++) {
- RoaringBitmap value = RoaringBitmapUtils.deserialize(bytesValues[i]);
int groupKey = groupKeyArray[i];
- RoaringBitmap valueBitmap = groupByResultHolder.getResult(groupKey);
- if (valueBitmap != null) {
- valueBitmap.or(value);
- } else {
- groupByResultHolder.setValueForKey(groupKey, value);
- }
+ Object result = groupByResultHolder.getResult(groupKey);
+ groupByResultHolder.setValueForKey(groupKey,
+ result == null ?
RoaringBitmapUtils.deserializeToUnion(bytesValues[i])
+ : addBitmap(result,
RoaringBitmapUtils.deserialize(bytesValues[i])));
}
});
return;
@@ -461,12 +454,12 @@ public class DistinctCountBitmapAggregationFunction
extends BaseSingleInputAggre
for (int i = from; i < to; i++) {
RoaringBitmap value = RoaringBitmapUtils.deserialize(bytesValues[i]);
for (int groupKey : groupKeysArray[i]) {
- RoaringBitmap bitmap = groupByResultHolder.getResult(groupKey);
- if (bitmap != null) {
- bitmap.or(value);
+ Object result = groupByResultHolder.getResult(groupKey);
+ if (result != null) {
+ groupByResultHolder.setValueForKey(groupKey, addBitmap(result,
value));
} else {
- // Clone a bitmap for the group
- groupByResultHolder.setValueForKey(groupKey, value.clone());
+ // Each group owns an independent accumulator: the same input is
added to every group of the row
+ groupByResultHolder.setValueForKey(groupKey, addBitmap(new
RoaringBitmapUnion(), value));
}
}
}
@@ -661,10 +654,16 @@ public class DistinctCountBitmapAggregationFunction
extends BaseSingleInputAggre
if (result instanceof DictIdsWrapper) {
// For dictionary-encoded expression, convert dictionary ids to hash
code of the values
return convertToValueBitmap((DictIdsWrapper) result);
- } else {
- // For serialized RoaringBitmap and non-dictionary-encoded expression,
directly return the value bitmap
- return (RoaringBitmap) result;
}
+ if (result instanceof RoaringBitmapUnion) {
+ // Serialized RoaringBitmap expression: finalize the lazy accumulator
once and publish the plain bitmap, so
+ // repeated extraction returns the same instance and the union no longer
references it
+ RoaringBitmap bitmap = ((RoaringBitmapUnion) result).take();
+ aggregationResultHolder.setValue(bitmap);
+ return bitmap;
+ }
+ // For non-dictionary-encoded expression (or an already published bitmap),
directly return the value bitmap
+ return (RoaringBitmap) result;
}
@Override
@@ -677,10 +676,31 @@ public class DistinctCountBitmapAggregationFunction
extends BaseSingleInputAggre
if (result instanceof DictIdsWrapper) {
// For dictionary-encoded expression, convert dictionary ids to hash
code of the values
return convertToValueBitmap((DictIdsWrapper) result);
+ }
+ if (result instanceof RoaringBitmapUnion) {
+ // See extractAggregationResult()
+ RoaringBitmap bitmap = ((RoaringBitmapUnion) result).take();
+ groupByResultHolder.setValueForKey(groupKey, bitmap);
+ return bitmap;
+ }
+ // For non-dictionary-encoded expression (or an already published bitmap),
directly return the value bitmap
+ return (RoaringBitmap) result;
+ }
+
+ /// Unions a deserialized bitmap into the accumulator of a serialized-bitmap
aggregation. The accumulator is a
+ /// [RoaringBitmapUnion] while aggregating; a plain [RoaringBitmap] means
the result was already published by
+ /// extraction (no operator aggregates again after extracting today, so this
path only keeps a published bitmap
+ /// immutable), in which case a fresh accumulator is started from a copy of
it.
+ private static RoaringBitmapUnion addBitmap(Object result, RoaringBitmap
input) {
+ RoaringBitmapUnion union;
+ if (result instanceof RoaringBitmapUnion) {
+ union = (RoaringBitmapUnion) result;
} else {
- // For serialized RoaringBitmap and non-dictionary-encoded expression,
directly return the value bitmap
- return (RoaringBitmap) result;
+ union = new RoaringBitmapUnion();
+ union.add((RoaringBitmap) result);
}
+ union.add(input);
+ return union;
}
@Override
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapLazyUnionTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapLazyUnionTest.java
new file mode 100644
index 00000000000..84d846edba1
--- /dev/null
+++
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapLazyUnionTest.java
@@ -0,0 +1,221 @@
+/**
+ * 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.pinot.core.query.aggregation.function;
+
+import java.util.Map;
+import java.util.Random;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.groupby.GroupByResultHolder;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.mockito.Mockito;
+import org.roaringbitmap.RoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertSame;
+
+
+/// Covers the serialized-bitmap (`BYTES`) aggregation paths of
[DistinctCountBitmapAggregationFunction], which union
+/// input bitmaps lazily and repair the accumulator at extraction. The input
cardinalities are chosen to cross the
+/// container thresholds of the lazy union (array containers promote to bitmap
containers past 1024 combined
+/// cardinality; repair converts back to array containers at up to 4096), so
both promoted and non-promoted
+/// accumulator states are verified against an eagerly unioned reference.
+public class DistinctCountBitmapLazyUnionTest {
+ private static final ExpressionContext EXPRESSION =
ExpressionContext.forIdentifier("bitmapCol");
+ private static final Random RANDOM = new Random(42);
+
+ private static byte[][] serializedBitmaps(int numBitmaps, int
valuesPerBitmap, int maxValue) {
+ byte[][] serialized = new byte[numBitmaps][];
+ for (int i = 0; i < numBitmaps; i++) {
+ RoaringBitmap bitmap = new RoaringBitmap();
+ for (int j = 0; j < valuesPerBitmap; j++) {
+ bitmap.add(RANDOM.nextInt(maxValue));
+ }
+ serialized[i] = RoaringBitmapUtils.serialize(bitmap);
+ }
+ return serialized;
+ }
+
+ private static RoaringBitmap eagerUnion(byte[][] serialized, int from, int
to) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = from; i < to; i++) {
+ expected.or(RoaringBitmapUtils.deserialize(serialized[i]));
+ }
+ return expected;
+ }
+
+ private static Map<ExpressionContext, BlockValSet>
mockBlockValSetMap(byte[][] serialized) {
+ return mockBlockValSetMap(serialized, null);
+ }
+
+ private static Map<ExpressionContext, BlockValSet>
mockBlockValSetMap(byte[][] serialized,
+ @Nullable RoaringBitmap nullBitmap) {
+ BlockValSet blockValSet = Mockito.mock(BlockValSet.class);
+ Mockito.when(blockValSet.getValueType()).thenReturn(DataType.BYTES);
+ Mockito.when(blockValSet.isSingleValue()).thenReturn(true);
+ Mockito.when(blockValSet.getBytesValuesSV()).thenReturn(serialized);
+ Mockito.when(blockValSet.getNullBitmap()).thenReturn(nullBitmap);
+ return Map.of(EXPRESSION, blockValSet);
+ }
+
+ @Test
+ public void testAggregateSkipsNullRowsAcrossRanges() {
+ // With null handling enabled the block is consumed as several non-null
ranges; the accumulator is re-read from
+ // the holder at the start of each range and written back at its end, and
null rows never reach the union
+ byte[][] serialized = serializedBitmaps(120, 50, 100_000);
+ RoaringBitmap nullBitmap = RoaringBitmap.bitmapOf(0, 1, 17, 40, 41, 42,
99, 119);
+ DistinctCountBitmapAggregationFunction function =
+ new DistinctCountBitmapAggregationFunction(EXPRESSION, true);
+ AggregationResultHolder holder = function.createAggregationResultHolder();
+ int blockSize = 60;
+ for (int from = 0; from < serialized.length; from += blockSize) {
+ byte[][] block = new byte[blockSize][];
+ System.arraycopy(serialized, from, block, 0, blockSize);
+ RoaringBitmap blockNulls = new RoaringBitmap();
+ for (int i = 0; i < blockSize; i++) {
+ if (nullBitmap.contains(from + i)) {
+ blockNulls.add(i);
+ }
+ }
+ function.aggregate(blockSize, holder, mockBlockValSetMap(block,
blockNulls));
+ }
+
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < serialized.length; i++) {
+ if (!nullBitmap.contains(i)) {
+ expected.or(RoaringBitmapUtils.deserialize(serialized[i]));
+ }
+ }
+ RoaringBitmap result = function.extractAggregationResult(holder);
+ assertEquals(result, expected);
+ assertEquals(result.getCardinality(), expected.getCardinality());
+ assertFalse(result.isEmpty());
+ }
+
+ @Test
+ public void testAggregateAcrossBlocks() {
+ // Enough overlap-heavy inputs to promote accumulator containers to (lazy)
bitmap containers, split into
+ // multiple aggregate() calls to verify the accumulator stays valid across
blocks until extraction. An early
+ // extraction after the first block guards the published-bitmap safety
net: no operator extracts and then keeps
+ // aggregating today, but if one did, the published bitmap must stay
immutable and later extraction must still
+ // be complete.
+ byte[][] serialized = serializedBitmaps(200, 100, 200_000);
+ DistinctCountBitmapAggregationFunction function =
+ new DistinctCountBitmapAggregationFunction(EXPRESSION, false);
+ AggregationResultHolder holder = function.createAggregationResultHolder();
+ int blockSize = 50;
+ RoaringBitmap published = null;
+ RoaringBitmap publishedCopy = null;
+ for (int from = 0; from < serialized.length; from += blockSize) {
+ byte[][] block = new byte[blockSize][];
+ System.arraycopy(serialized, from, block, 0, blockSize);
+ function.aggregate(blockSize, holder, mockBlockValSetMap(block));
+ if (published != null) {
+ assertEquals(published, publishedCopy);
+ assertEquals(published.getCardinality(),
publishedCopy.getCardinality());
+ }
+ if (from == 0) {
+ published = function.extractAggregationResult(holder);
+ assertEquals(published, eagerUnion(serialized, 0, blockSize));
+ // Repeated extraction returns the same published instance
+ assertSame(function.extractAggregationResult(holder), published);
+ publishedCopy = published.clone();
+ }
+ }
+
+ RoaringBitmap result = function.extractAggregationResult(holder);
+ RoaringBitmap expected = eagerUnion(serialized, 0, serialized.length);
+ assertEquals(result, expected);
+ assertEquals(result.getCardinality(), expected.getCardinality());
+ // The repaired accumulator must serialize into a form that round-trips
+
assertEquals(RoaringBitmapUtils.deserialize(RoaringBitmapUtils.serialize(result)),
expected);
+ }
+
+ @Test
+ public void testAggregateGroupBySV() {
+ int numGroups = 4;
+ byte[][] serialized = serializedBitmaps(400, 50, 100_000);
+ int[] groupKeys = new int[serialized.length];
+ for (int i = 0; i < groupKeys.length; i++) {
+ groupKeys[i] = i % numGroups;
+ }
+
+ DistinctCountBitmapAggregationFunction function =
+ new DistinctCountBitmapAggregationFunction(EXPRESSION, false);
+ GroupByResultHolder holder = function.createGroupByResultHolder(numGroups,
numGroups);
+ function.aggregateGroupBySV(serialized.length, groupKeys, holder,
mockBlockValSetMap(serialized));
+
+ for (int groupKey = 0; groupKey < numGroups; groupKey++) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = groupKey; i < serialized.length; i += numGroups) {
+ expected.or(RoaringBitmapUtils.deserialize(serialized[i]));
+ }
+ RoaringBitmap result = function.extractGroupByResult(holder, groupKey);
+ assertEquals(result, expected, "group " + groupKey);
+ assertEquals(result.getCardinality(), expected.getCardinality(), "group
" + groupKey);
+ }
+ }
+
+ @Test
+ public void testAggregateGroupByMV() {
+ // Every row belongs to multiple groups, so the same deserialized input
bitmap is unioned into one group's
+ // accumulator and cloned as another group's initial accumulator —
verifies no aliasing between accumulators
+ int numGroups = 3;
+ byte[][] serialized = serializedBitmaps(150, 80, 150_000);
+ int[][] groupKeys = new int[serialized.length][];
+ for (int i = 0; i < groupKeys.length; i++) {
+ groupKeys[i] = new int[]{i % numGroups, (i + 1) % numGroups};
+ }
+
+ DistinctCountBitmapAggregationFunction function =
+ new DistinctCountBitmapAggregationFunction(EXPRESSION, false);
+ GroupByResultHolder holder = function.createGroupByResultHolder(numGroups,
numGroups);
+ function.aggregateGroupByMV(serialized.length, groupKeys, holder,
mockBlockValSetMap(serialized));
+
+ for (int groupKey = 0; groupKey < numGroups; groupKey++) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (int i = 0; i < serialized.length; i++) {
+ if (i % numGroups == groupKey || (i + 1) % numGroups == groupKey) {
+ expected.or(RoaringBitmapUtils.deserialize(serialized[i]));
+ }
+ }
+ RoaringBitmap result = function.extractGroupByResult(holder, groupKey);
+ assertEquals(result, expected, "group " + groupKey);
+ assertEquals(result.getCardinality(), expected.getCardinality(), "group
" + groupKey);
+ }
+ }
+
+ @Test
+ public void testSmallBitmapsStayCorrectBelowPromotionThreshold() {
+ // All containers stay small array containers: the lazy path must degrade
to plain array unions
+ byte[][] serialized = serializedBitmaps(50, 5, 1_000);
+ DistinctCountBitmapAggregationFunction function =
+ new DistinctCountBitmapAggregationFunction(EXPRESSION, false);
+ AggregationResultHolder holder = function.createAggregationResultHolder();
+ function.aggregate(serialized.length, holder,
mockBlockValSetMap(serialized));
+
+ RoaringBitmap expected = eagerUnion(serialized, 0, serialized.length);
+ assertEquals(function.extractAggregationResult(holder), expected);
+ }
+}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
index 79ac5a7dd29..24dbabe15b0 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
@@ -179,11 +179,8 @@ abstract class BaseStarTreeV2Test<R, A> {
_valueAggregator.getAggregationType().getName(), null,
getCompressionCodec(),
true, getIndexVersion(), null, null)), MAX_LEAF_RECORDS);
File indexDir = new File(TEMP_DIR, SEGMENT_NAME);
- // Randomly build star-tree using on-heap or off-heap mode
- MultipleTreesBuilder.BuildMode buildMode =
- RANDOM.nextBoolean() ? MultipleTreesBuilder.BuildMode.ON_HEAP :
MultipleTreesBuilder.BuildMode.OFF_HEAP;
try (MultipleTreesBuilder builder = new
MultipleTreesBuilder(List.of(starTreeIndexConfig), false,
- indexDir, buildMode)) {
+ indexDir, getBuildMode())) {
builder.build();
}
@@ -494,6 +491,12 @@ abstract class BaseStarTreeV2Test<R, A> {
return compressionCodec.isApplicableToRawIndex() ? compressionCodec : null;
}
+ /// Star-tree build mode. Randomized by default so both builders are
exercised over time; tests that need a
+ /// specific builder override this.
+ MultipleTreesBuilder.BuildMode getBuildMode() {
+ return RANDOM.nextBoolean() ? MultipleTreesBuilder.BuildMode.ON_HEAP :
MultipleTreesBuilder.BuildMode.OFF_HEAP;
+ }
+
/// Can be overridden to force the index version.
@Nullable
Integer getIndexVersion() {
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
index 044a9143837..ebc38c0c728 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
@@ -19,18 +19,19 @@
package org.apache.pinot.core.startree.v2;
import java.util.Random;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
import
org.apache.pinot.segment.local.aggregator.DistinctCountBitmapValueAggregator;
import org.apache.pinot.segment.local.aggregator.ValueAggregator;
+import org.apache.pinot.segment.local.startree.v2.builder.MultipleTreesBuilder;
import org.apache.pinot.spi.data.FieldSpec.DataType;
-import org.roaringbitmap.RoaringBitmap;
import static org.testng.Assert.assertEquals;
-public class DistinctCountBitmapStarTreeV2Test extends
BaseStarTreeV2Test<Object, RoaringBitmap> {
+public class DistinctCountBitmapStarTreeV2Test extends
BaseStarTreeV2Test<Object, RoaringBitmapUnion> {
@Override
- ValueAggregator<Object, RoaringBitmap> getValueAggregator() {
+ ValueAggregator<Object, RoaringBitmapUnion> getValueAggregator() {
return new DistinctCountBitmapValueAggregator();
}
@@ -45,7 +46,14 @@ public class DistinctCountBitmapStarTreeV2Test extends
BaseStarTreeV2Test<Object
}
@Override
- void assertAggregatedValue(RoaringBitmap starTreeResult, RoaringBitmap
nonStarTreeResult) {
- assertEquals(starTreeResult, nonStarTreeResult);
+ MultipleTreesBuilder.BuildMode getBuildMode() {
+ // Pin the on-heap builder so its path is always covered: it
pre-serializes the variable-length bitmap metric
+ // before sizing the forward index
+ return MultipleTreesBuilder.BuildMode.ON_HEAP;
+ }
+
+ @Override
+ void assertAggregatedValue(RoaringBitmapUnion starTreeResult,
RoaringBitmapUnion nonStarTreeResult) {
+ assertEquals(starTreeResult.get(), nonStarTreeResult.get());
}
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapWithMVStarTreeV2Test.java
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapWithMVStarTreeV2Test.java
index b58a9d31cc6..194526adf0a 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapWithMVStarTreeV2Test.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapWithMVStarTreeV2Test.java
@@ -19,18 +19,19 @@
package org.apache.pinot.core.startree.v2;
import java.util.Random;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
import
org.apache.pinot.segment.local.aggregator.DistinctCountBitmapValueAggregator;
import org.apache.pinot.segment.local.aggregator.ValueAggregator;
+import org.apache.pinot.segment.local.startree.v2.builder.MultipleTreesBuilder;
import org.apache.pinot.spi.data.FieldSpec.DataType;
-import org.roaringbitmap.RoaringBitmap;
import static org.testng.Assert.assertEquals;
-public class DistinctCountBitmapWithMVStarTreeV2Test extends
BaseStarTreeV2Test<Object, RoaringBitmap> {
+public class DistinctCountBitmapWithMVStarTreeV2Test extends
BaseStarTreeV2Test<Object, RoaringBitmapUnion> {
@Override
- ValueAggregator<Object, RoaringBitmap> getValueAggregator() {
+ ValueAggregator<Object, RoaringBitmapUnion> getValueAggregator() {
return new DistinctCountBitmapValueAggregator();
}
@@ -45,8 +46,14 @@ public class DistinctCountBitmapWithMVStarTreeV2Test extends
BaseStarTreeV2Test<
}
@Override
- void assertAggregatedValue(RoaringBitmap starTreeResult, RoaringBitmap
nonStarTreeResult) {
- assertEquals(starTreeResult, nonStarTreeResult);
+ MultipleTreesBuilder.BuildMode getBuildMode() {
+ // Pin the off-heap builder so its path is always covered: it serializes
every record when it is appended
+ return MultipleTreesBuilder.BuildMode.OFF_HEAP;
+ }
+
+ @Override
+ void assertAggregatedValue(RoaringBitmapUnion starTreeResult,
RoaringBitmapUnion nonStarTreeResult) {
+ assertEquals(starTreeResult.get(), nonStarTreeResult.get());
}
@Override
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountHLLOnHeapStarTreeV2Test.java
similarity index 50%
copy from
pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
copy to
pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountHLLOnHeapStarTreeV2Test.java
index 044a9143837..2f456424e8b 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountBitmapStarTreeV2Test.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/DistinctCountHLLOnHeapStarTreeV2Test.java
@@ -18,34 +18,16 @@
*/
package org.apache.pinot.core.startree.v2;
-import java.util.Random;
-import
org.apache.pinot.segment.local.aggregator.DistinctCountBitmapValueAggregator;
-import org.apache.pinot.segment.local.aggregator.ValueAggregator;
-import org.apache.pinot.spi.data.FieldSpec.DataType;
-import org.roaringbitmap.RoaringBitmap;
+import org.apache.pinot.segment.local.startree.v2.builder.MultipleTreesBuilder;
-import static org.testng.Assert.assertEquals;
-
-public class DistinctCountBitmapStarTreeV2Test extends
BaseStarTreeV2Test<Object, RoaringBitmap> {
-
- @Override
- ValueAggregator<Object, RoaringBitmap> getValueAggregator() {
- return new DistinctCountBitmapValueAggregator();
- }
-
- @Override
- DataType getRawValueType() {
- return DataType.INT;
- }
-
- @Override
- Object getRandomRawValue(Random random) {
- return random.nextInt(100);
- }
+/// Runs [DistinctCountHLLStarTreeV2Test] with the on-heap builder pinned, so
that its pass that serializes
+/// variable-length metrics before the forward indexes are sized is always
covered for an aggregator other than the
+/// bitmap one. The parent class keeps drawing the build mode at random.
+public class DistinctCountHLLOnHeapStarTreeV2Test extends
DistinctCountHLLStarTreeV2Test {
@Override
- void assertAggregatedValue(RoaringBitmap starTreeResult, RoaringBitmap
nonStarTreeResult) {
- assertEquals(starTreeResult, nonStarTreeResult);
+ MultipleTreesBuilder.BuildMode getBuildMode() {
+ return MultipleTreesBuilder.BuildMode.ON_HEAP;
}
}
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/aggregation/BenchmarkDistinctCountBitmapAggregation.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/aggregation/BenchmarkDistinctCountBitmapAggregation.java
new file mode 100644
index 00000000000..78f9604166b
--- /dev/null
+++
b/pinot-perf/src/main/java/org/apache/pinot/perf/aggregation/BenchmarkDistinctCountBitmapAggregation.java
@@ -0,0 +1,121 @@
+/**
+ * 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.pinot.perf.aggregation;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
+import org.apache.pinot.core.plan.DocIdSetPlanNode;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
+import
org.apache.pinot.core.query.aggregation.function.DistinctCountBitmapAggregationFunction;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+import org.roaringbitmap.RoaringBitmap;
+
+
+/// Benchmarks the serialized-bitmap (`BYTES`) aggregation path of
DISTINCT_COUNT_BITMAP, i.e. the path used when
+/// unioning pre-aggregated bitmaps such as star-tree DISTINCT_COUNT_BITMAP
columns. `_valuesPerBitmap` controls the
+/// input bitmap size and `_maxValue` the value universe: a small universe
produces dense accumulator containers
+/// (bitmap containers), a large one sparse accumulators (array containers).
+@Fork(1)
+@BenchmarkMode(Mode.Throughput)
+@Warmup(iterations = 30, time = 100, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 30, time = 100, timeUnit = TimeUnit.MILLISECONDS)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@State(Scope.Benchmark)
+public class BenchmarkDistinctCountBitmapAggregation extends
AbstractAggregationFunctionBenchmark.Stable {
+ private static final ExpressionContext EXPR =
ExpressionContext.forIdentifier("col");
+
+ @Param({"200", "2000"})
+ private int _valuesPerBitmap;
+
+ // The last universe spreads values over 32768 container keys, like hash
codes of a non-numeric column do
+ @Param({"2000000", "100000000", "2147483647"})
+ private int _maxValue;
+
+ public static void main(String[] args) throws RunnerException {
+ Options opt =
+ new
OptionsBuilder().include(BenchmarkDistinctCountBitmapAggregation.class.getSimpleName()).build();
+ new Runner(opt).run();
+ }
+
+ @Override
+ protected AggregationFunction<?, ?> createAggregationFunction() {
+ return new DistinctCountBitmapAggregationFunction(List.of(EXPR), false);
+ }
+
+ @Override
+ protected AggregationResultHolder createResultHolder() {
+ return getAggregationFunction().createAggregationResultHolder();
+ }
+
+ @Override
+ protected Map<ExpressionContext, BlockValSet> createBlockValSetMap() {
+ Random random = new Random(420);
+ int numDocs = DocIdSetPlanNode.MAX_DOC_PER_CALL;
+ BlockValSet block = SyntheticBlockValSets.Bytes.create(numDocs, null, ()
-> {
+ RoaringBitmap bitmap = new RoaringBitmap();
+ for (int i = 0; i < _valuesPerBitmap; i++) {
+ bitmap.add(random.nextInt(_maxValue));
+ }
+ return RoaringBitmapUtils.serialize(bitmap);
+ });
+ return Map.of(EXPR, block);
+ }
+
+ @Override
+ protected Object createExpectedResult(Map<ExpressionContext, BlockValSet>
map) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (byte[] serialized : map.get(EXPR).getBytesValuesSV()) {
+ expected.or(RoaringBitmapUtils.deserialize(serialized));
+ }
+ return expected.getCardinality();
+ }
+
+ @Override
+ protected Comparable extractFinalResult(AggregationResultHolder
resultHolder) {
+ // Route through extractAggregationResult(): the holder's accumulator is
not directly consumable (it may be in
+ // the lazy union state that extraction repairs), matching how the query
engine reads aggregation results
+ DistinctCountBitmapAggregationFunction function =
+ (DistinctCountBitmapAggregationFunction) getAggregationFunction();
+ return
function.extractFinalResult(function.extractAggregationResult(resultHolder));
+ }
+
+ @Override
+ protected void resetResultHolder(AggregationResultHolder resultHolder) {
+ resultHolder.setValue(null);
+ }
+}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregator.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregator.java
index 74fbb95f2d8..827dec88fad 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregator.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregator.java
@@ -18,13 +18,21 @@
*/
package org.apache.pinot.segment.local.aggregator;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
import org.apache.pinot.common.utils.RoaringBitmapUtils;
import org.apache.pinot.segment.spi.AggregationFunctionType;
import org.apache.pinot.spi.data.FieldSpec.DataType;
-import org.roaringbitmap.RoaringBitmap;
-public class DistinctCountBitmapValueAggregator implements
ValueAggregator<Object, RoaringBitmap> {
+/// Pre-aggregates distinct values into a [RoaringBitmapUnion], which unions
serialized-bitmap (`byte[]`) inputs
+/// lazily and finalizes the bitmap only when it is read or serialized. A
given metric column always provides either
+/// serialized bitmaps or plain raw values, never both.
+///
+/// [#getMaxAggregatedValueByteSize] reports the largest value produced by
[#serializeAggregatedValue] so far. This
+/// aggregator is only used by the star-tree builders (it has no fixed size,
so ingestion-time aggregation rejects
+/// it), and both builders serialize every record before that size is
consumed: the off-heap builder when it appends
+/// a record, the on-heap builder in its pre-serialization pass before the
forward indexes are sized.
+public class DistinctCountBitmapValueAggregator implements
ValueAggregator<Object, RoaringBitmapUnion> {
public static final DataType AGGREGATED_VALUE_TYPE = DataType.BYTES;
private int _maxByteSize;
@@ -40,57 +48,53 @@ public class DistinctCountBitmapValueAggregator implements
ValueAggregator<Objec
}
@Override
- public RoaringBitmap getInitialAggregatedValue(Object rawValue) {
+ public RoaringBitmapUnion getInitialAggregatedValue(Object rawValue) {
// NOTE: rawValue cannot be null because this aggregator can only be used
for star-tree index, and the builder
// never passes a null raw value: a null-aware star-tree leaves the
aggregated value null until the group sees
// its first non-null input.
assert rawValue != null;
- RoaringBitmap initialValue;
if (rawValue instanceof byte[]) {
- byte[] bytes = (byte[]) rawValue;
- initialValue = deserializeAggregatedValue(bytes);
- _maxByteSize = Math.max(_maxByteSize, bytes.length);
- } else {
- initialValue = new RoaringBitmap();
- addToValue(initialValue, rawValue);
- _maxByteSize = Math.max(_maxByteSize,
initialValue.serializedSizeInBytes());
+ return deserializeAggregatedValue((byte[]) rawValue);
}
+ RoaringBitmapUnion initialValue = new RoaringBitmapUnion();
+ addToValue(initialValue, rawValue);
return initialValue;
}
@Override
- public RoaringBitmap applyRawValue(RoaringBitmap value, Object rawValue) {
+ public RoaringBitmapUnion applyRawValue(RoaringBitmapUnion value, Object
rawValue) {
if (rawValue instanceof byte[]) {
- value.or(deserializeAggregatedValue((byte[]) rawValue));
+ value.add(RoaringBitmapUtils.deserialize((byte[]) rawValue));
} else {
addToValue(value, rawValue);
}
- _maxByteSize = Math.max(_maxByteSize, value.serializedSizeInBytes());
return value;
}
- /// Adds a raw value (single value or multi-value array) to the
RoaringBitmap.
- protected void addToValue(RoaringBitmap bitmap, Object rawValue) {
+ /// Adds a raw value (single value or multi-value array) to the union.
+ protected void addToValue(RoaringBitmapUnion union, Object rawValue) {
if (rawValue instanceof Object[]) {
Object[] values = (Object[]) rawValue;
for (Object value : values) {
- bitmap.add(value.hashCode());
+ union.add(value.hashCode());
}
} else {
- bitmap.add(rawValue.hashCode());
+ union.add(rawValue.hashCode());
}
}
@Override
- public RoaringBitmap applyAggregatedValue(RoaringBitmap value, RoaringBitmap
aggregatedValue) {
- value.or(aggregatedValue);
- _maxByteSize = Math.max(_maxByteSize, value.serializedSizeInBytes());
+ public RoaringBitmapUnion applyAggregatedValue(RoaringBitmapUnion value,
RoaringBitmapUnion aggregatedValue) {
+ // get() finalizes the other accumulator without consuming it; it stays
usable (and is serialized later itself)
+ value.add(aggregatedValue.get());
return value;
}
@Override
- public RoaringBitmap cloneAggregatedValue(RoaringBitmap value) {
- return value.clone();
+ public RoaringBitmapUnion cloneAggregatedValue(RoaringBitmapUnion value) {
+ RoaringBitmapUnion clone = new RoaringBitmapUnion();
+ clone.add(value.get());
+ return clone;
}
@Override
@@ -104,12 +108,14 @@ public class DistinctCountBitmapValueAggregator
implements ValueAggregator<Objec
}
@Override
- public byte[] serializeAggregatedValue(RoaringBitmap value) {
- return RoaringBitmapUtils.serialize(value);
+ public byte[] serializeAggregatedValue(RoaringBitmapUnion value) {
+ byte[] bytes = RoaringBitmapUtils.serialize(value.get());
+ _maxByteSize = Math.max(_maxByteSize, bytes.length);
+ return bytes;
}
@Override
- public RoaringBitmap deserializeAggregatedValue(byte[] bytes) {
- return RoaringBitmapUtils.deserialize(bytes);
+ public RoaringBitmapUnion deserializeAggregatedValue(byte[] bytes) {
+ return RoaringBitmapUtils.deserializeToUnion(bytes);
}
}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/ValueAggregator.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/ValueAggregator.java
index 350535d76d9..6ee71277681 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/ValueAggregator.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/ValueAggregator.java
@@ -81,6 +81,11 @@ public interface ValueAggregator<R, A> {
boolean isAggregatedValueFixedSize();
/// Returns the maximum size in bytes of the aggregated values seen so far.
+ ///
+ /// For variable-size (`BYTES`) aggregated values this is only complete once
every value has been serialized
+ /// through [#serializeAggregatedValue]: an aggregator may derive the size
from the values it serialized rather than
+ /// measure every intermediate state. Builders must therefore serialize all
values before consuming the size, as the
+ /// star-tree builders do.
int getMaxAggregatedValueByteSize();
/// Serializes an aggregated value into a byte array.
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/json/MutableJsonIndexImpl.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/json/MutableJsonIndexImpl.java
index 4b9a3c99c55..8010ab5d20d 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/json/MutableJsonIndexImpl.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/json/MutableJsonIndexImpl.java
@@ -48,6 +48,7 @@ import
org.apache.pinot.common.request.context.predicate.NotInPredicate;
import org.apache.pinot.common.request.context.predicate.Predicate;
import org.apache.pinot.common.request.context.predicate.RangePredicate;
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
import org.apache.pinot.common.utils.SegmentUtils;
import org.apache.pinot.common.utils.regex.Matcher;
import org.apache.pinot.common.utils.regex.Pattern;
@@ -200,6 +201,40 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
return predicateType == Predicate.Type.IS_NULL;
}
+ /// Folds many posting lists into one [LazyBitmap] through a
[RoaringBitmapUnion], which unions lazily and
+ /// finalizes the bitmap once at [#get]. A single input is borrowed: it is
wrapped as an immutable [LazyBitmap],
+ /// never adopted or modified.
+ private static class UnionAccumulator {
+ @Nullable
+ private RoaringBitmap _first;
+ @Nullable
+ private RoaringBitmapUnion _union;
+
+ void add(RoaringBitmap docIds) {
+ if (docIds.isEmpty()) {
+ return;
+ }
+ if (_union != null) {
+ _union.add(docIds);
+ } else if (_first == null) {
+ _first = docIds;
+ } else {
+ // The borrowed first posting list is copied into the union, never
adopted
+ _union = new RoaringBitmapUnion();
+ _union.add(_first);
+ _union.add(docIds);
+ _first = null;
+ }
+ }
+
+ LazyBitmap get() {
+ if (_union != null) {
+ return LazyBitmap.createMutable(_union.take());
+ }
+ return _first != null ? LazyBitmap.createImmutable(_first) :
LazyBitmap.EMPTY_BITMAP;
+ }
+ }
+
/// This class allows delaying of cloning posting list bitmap for as long as
possible
/// It stores either a bitmap from posting list that must be cloned before
mutating (readOnly=true)
/// or an already cloned bitmap.
@@ -280,20 +315,6 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
return createMutable(RoaringBitmap.or(_value, other._value));
}
- LazyBitmap or(RoaringBitmap bitmap) {
- if (isEmpty()) {
- return createImmutable(bitmap);
- }
- if (bitmap.isEmpty()) {
- return this;
- }
- if (isMutable()) {
- _value.or(bitmap);
- return this;
- }
- return createMutable(RoaringBitmap.or(_value, bitmap));
- }
-
LazyBitmap andNot(LazyBitmap other) {
if (isEmpty()) {
return EMPTY_BITMAP;
@@ -438,17 +459,17 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
StringBuilder buffer = new StringBuilder(key);
buffer.append(JsonIndexCreator.KEY_VALUE_SEPARATOR);
int pos = buffer.length();
- LazyBitmap result = LazyBitmap.EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
List<String> values = ((InPredicate) predicate).getValues();
for (String value : values) {
buffer.setLength(pos);
buffer.append(value);
RoaringBitmap docIds = _postingListMap.get(buffer.toString());
if (docIds != null) {
- result = result.or(docIds);
+ result.add(docIds);
}
}
- return result;
+ return result.get();
}
case NOT_IN: {
@@ -488,7 +509,7 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
}
Pattern pattern = ((RegexpLikePredicate) predicate).getPattern();
Matcher matcher = pattern.matcher("");
- LazyBitmap result = LazyBitmap.EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
StringBuilder value = new StringBuilder();
int valueStart = key.length() + 1;
for (Map.Entry<String, RoaringBitmap> entry : subMap.entrySet()) {
@@ -496,10 +517,10 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
value.setLength(0);
value.append(keyValue, valueStart, keyValue.length());
if (matcher.reset(value).matches()) {
- result = result.or(entry.getValue());
+ result.add(entry.getValue());
}
}
- return result;
+ return result.get();
}
case RANGE: {
@@ -521,7 +542,7 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
boolean upperInclusive = upperUnbounded ||
rangePredicate.isUpperInclusive();
Object lowerBound = lowerUnbounded ? null :
rangeDataType.convert(rangePredicate.getLowerBound());
Object upperBound = upperUnbounded ? null :
rangeDataType.convert(rangePredicate.getUpperBound());
- LazyBitmap result = LazyBitmap.EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
int valueStart = key.length() + 1;
for (Map.Entry<String, RoaringBitmap> entry : subMap.entrySet()) {
Object valueObj =
rangeDataType.convert(entry.getKey().substring(valueStart));
@@ -532,10 +553,10 @@ public class MutableJsonIndexImpl implements
MutableJsonIndex {
upperUnbounded || (upperInclusive ?
rangeDataType.compare(valueObj, upperBound) <= 0
: rangeDataType.compare(valueObj, upperBound) < 0);
if (lowerCompareResult && upperCompareResult) {
- result = result.or(entry.getValue());
+ result.add(entry.getValue());
}
}
- return result;
+ return result.get();
}
default:
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/json/ImmutableJsonIndexReader.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/json/ImmutableJsonIndexReader.java
index 242185c2be3..8e759ea57f6 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/json/ImmutableJsonIndexReader.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/json/ImmutableJsonIndexReader.java
@@ -46,6 +46,7 @@ import
org.apache.pinot.common.request.context.predicate.NotInPredicate;
import org.apache.pinot.common.request.context.predicate.Predicate;
import org.apache.pinot.common.request.context.predicate.RangePredicate;
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
+import org.apache.pinot.common.utils.MutableRoaringBitmapUnion;
import org.apache.pinot.common.utils.regex.Matcher;
import org.apache.pinot.common.utils.regex.Pattern;
import
org.apache.pinot.segment.local.segment.creator.impl.inv.json.BaseJsonIndexCreator;
@@ -176,6 +177,40 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
return ImmutableRoaringBitmap.and(target, other);
}
+ /// Folds many posting lists into one result through a
[MutableRoaringBitmapUnion], which unions lazily and
+ /// finalizes the bitmap once at [#get]. The zero- and single-input cases
return the borrowed posting list without
+ /// copying, matching the ownership behavior of [#or].
+ private static class UnionAccumulator {
+ @Nullable
+ private ImmutableRoaringBitmap _first;
+ @Nullable
+ private MutableRoaringBitmapUnion _union;
+
+ void add(ImmutableRoaringBitmap docIds) {
+ if (docIds.isEmpty()) {
+ return;
+ }
+ if (_union != null) {
+ _union.add(docIds);
+ } else if (_first == null) {
+ _first = docIds;
+ } else {
+ // The borrowed first posting list is copied into the union, never
adopted
+ _union = new MutableRoaringBitmapUnion();
+ _union.add(_first);
+ _union.add(docIds);
+ _first = null;
+ }
+ }
+
+ ImmutableRoaringBitmap get() {
+ if (_union != null) {
+ return _union.take();
+ }
+ return _first != null ? _first : EMPTY_BITMAP;
+ }
+ }
+
private static ImmutableRoaringBitmap or(ImmutableRoaringBitmap target,
ImmutableRoaringBitmap other) {
if (target.isEmpty()) {
return other;
@@ -320,17 +355,17 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
StringBuilder buffer = new StringBuilder(key);
buffer.append(JsonIndexCreator.KEY_VALUE_SEPARATOR);
int pos = buffer.length();
- ImmutableRoaringBitmap result = EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
List<String> values = ((InPredicate) predicate).getValues();
for (String value : values) {
buffer.setLength(pos);
buffer.append(value);
int dictId = _dictionary.indexOf(buffer.toString());
if (dictId >= 0) {
- result = or(result, _invertedIndex.getDocIds(dictId));
+ result.add(_invertedIndex.getDocIds(dictId));
}
}
- return result;
+ return result.get();
}
case NOT_IN: {
@@ -376,13 +411,13 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
notInDictIds.add(dictId);
}
}
- ImmutableRoaringBitmap result = EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
for (int dictId = dictIdRange[0]; dictId < dictIdRange[1]; dictId++)
{
if (!notInDictIds.contains(dictId)) {
- result = or(result, _invertedIndex.getDocIds(dictId));
+ result.add(_invertedIndex.getDocIds(dictId));
}
}
- return result;
+ return result.get();
}
}
@@ -404,7 +439,7 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
}
Pattern pattern = ((RegexpLikePredicate) predicate).getPattern();
Matcher matcher = pattern.matcher("");
- ImmutableRoaringBitmap result = EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
byte[] dictBuffer = _dictionary.getBuffer();
StringBuilder value = new StringBuilder();
int valueStart = key.length() + 1;
@@ -413,10 +448,10 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
value.setLength(0);
value.append(keyValue, valueStart, keyValue.length());
if (matcher.reset(value).matches()) {
- result = or(result, _invertedIndex.getDocIds(dictId));
+ result.add(_invertedIndex.getDocIds(dictId));
}
}
- return result;
+ return result.get();
}
case RANGE: {
@@ -439,7 +474,7 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
boolean upperInclusive = upperUnbounded ||
rangePredicate.isUpperInclusive();
Object lowerBound = lowerUnbounded ? null :
rangeDataType.convert(rangePredicate.getLowerBound());
Object upperBound = upperUnbounded ? null :
rangeDataType.convert(rangePredicate.getUpperBound());
- ImmutableRoaringBitmap result = EMPTY_BITMAP;
+ UnionAccumulator result = new UnionAccumulator();
byte[] dictBuffer = _dictionary.getBuffer();
int valueStart = key.length() + 1;
for (int dictId = dictIds[0]; dictId < dictIds[1]; dictId++) {
@@ -452,10 +487,10 @@ public class ImmutableJsonIndexReader implements
JsonIndexReader {
upperUnbounded || (upperInclusive ?
rangeDataType.compare(valueObj, upperBound) <= 0
: rangeDataType.compare(valueObj, upperBound) < 0);
if (lowerCompareResult && upperCompareResult) {
- result = or(result, _invertedIndex.getDocIds(dictId));
+ result.add(_invertedIndex.getDocIds(dictId));
}
}
- return result;
+ return result.get();
}
default:
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/BaseSingleTreeBuilder.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/BaseSingleTreeBuilder.java
index 5ef46fc14ed..c0aa0da4954 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/BaseSingleTreeBuilder.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/BaseSingleTreeBuilder.java
@@ -212,6 +212,17 @@ abstract class BaseSingleTreeBuilder implements
SingleTreeBuilder {
abstract Record getStarTreeRecord(int docId)
throws IOException;
+ /// Serializes metrics whose [ValueAggregator#getMaxAggregatedValueByteSize]
is derived during serialization before
+ /// the forward indexes are sized. Returns, per metric, whether
[#getStarTreeRecord] now holds the serialized
+ /// `byte[]` in place of the aggregated value. A builder that already
serialized every record when it was appended
+ /// (the off-heap builder) has nothing to do and returns all `false`.
+ ///
+ /// @return per-metric flag telling whether the star-tree records hold
pre-serialized values
+ boolean[] preSerializeMetrics()
+ throws IOException {
+ return new boolean[_numMetrics];
+ }
+
/// Returns the dimension value of the given document and dimension Id in
the star-tree.
///
/// @param docId Document Id
@@ -541,6 +552,9 @@ abstract class BaseSingleTreeBuilder implements
SingleTreeBuilder {
private void createForwardIndexes()
throws Exception {
+ // Finalize sizes for aggregators that track their maximum during
serialization before opening any creator.
+ boolean[] preSerializedMetrics = preSerializeMetrics();
+
SingleValueUnsortedForwardIndexCreator[] dimensionIndexCreators =
new SingleValueUnsortedForwardIndexCreator[_numDimensions];
for (int i = 0; i < _numDimensions; i++) {
@@ -613,6 +627,9 @@ abstract class BaseSingleTreeBuilder implements
SingleTreeBuilder {
if (value == null) {
metricNullValueVectorCreators[i].setNull(docId);
}
+ } else if (preSerializedMetrics[i]) {
+ metricIndexCreators[i].putBytes((byte[]) value);
+ continue;
}
putMetricValue(metricIndexCreators[i], valueAggregator, value);
}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/OnHeapSingleTreeBuilder.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/OnHeapSingleTreeBuilder.java
index 60c60aa67ce..88ccd3a2b3f 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/OnHeapSingleTreeBuilder.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/startree/v2/builder/OnHeapSingleTreeBuilder.java
@@ -24,6 +24,8 @@ import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import org.apache.commons.configuration2.Configuration;
+import
org.apache.pinot.segment.local.aggregator.DistinctCountBitmapValueAggregator;
+import org.apache.pinot.segment.local.aggregator.ValueAggregator;
import org.apache.pinot.segment.spi.ImmutableSegment;
import org.apache.pinot.segment.spi.index.startree.StarTreeV2Constants;
@@ -53,6 +55,28 @@ public class OnHeapSingleTreeBuilder extends
BaseSingleTreeBuilder {
return _records.get(docId);
}
+ @Override
+ @SuppressWarnings("unchecked")
+ boolean[] preSerializeMetrics() {
+ // Bitmap distinct count derives its maximum size during serialization.
Other aggregators already know their
+ // maximum size and can serialize during the index write without retaining
a byte array for every record.
+ boolean[] preSerializedMetrics = new boolean[_numMetrics];
+ for (int i = 0; i < _numMetrics; i++) {
+ ValueAggregator valueAggregator = _valueAggregators[i];
+ if (!(valueAggregator instanceof DistinctCountBitmapValueAggregator)) {
+ continue;
+ }
+ for (Record record : _records) {
+ Object value = record._metrics[i];
+ if (value != null) {
+ record._metrics[i] = valueAggregator.serializeAggregatedValue(value);
+ }
+ }
+ preSerializedMetrics[i] = true;
+ }
+ return preSerializedMetrics;
+ }
+
@Override
int getDimensionValue(int docId, int dimensionId) {
return _records.get(docId)._dimensions[dimensionId];
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregatorTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregatorTest.java
new file mode 100644
index 00000000000..92b5f11735c
--- /dev/null
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/aggregator/DistinctCountBitmapValueAggregatorTest.java
@@ -0,0 +1,161 @@
+/**
+ * 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.pinot.segment.local.aggregator;
+
+import java.util.Random;
+import org.apache.pinot.common.utils.RoaringBitmapUnion;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
+import org.roaringbitmap.RoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotSame;
+
+
+/// Covers the serialized-bitmap fold of [DistinctCountBitmapValueAggregator],
which unions inputs lazily through a
+/// [RoaringBitmapUnion] and finalizes the bitmap when it is read or
serialized. Input cardinalities cross the lazy
+/// container-promotion threshold so both promoted and non-promoted
accumulator states are verified against an eagerly
+/// unioned reference.
+public class DistinctCountBitmapValueAggregatorTest {
+ private static final Random RANDOM = new Random(42);
+
+ private static byte[][] serializedBitmaps(int numBitmaps, int
valuesPerBitmap, int maxValue) {
+ byte[][] serialized = new byte[numBitmaps][];
+ for (int i = 0; i < numBitmaps; i++) {
+ RoaringBitmap bitmap = new RoaringBitmap();
+ for (int j = 0; j < valuesPerBitmap; j++) {
+ bitmap.add(RANDOM.nextInt(maxValue));
+ }
+ serialized[i] = RoaringBitmapUtils.serialize(bitmap);
+ }
+ return serialized;
+ }
+
+ private static RoaringBitmap eagerUnion(byte[][] serialized) {
+ RoaringBitmap expected = new RoaringBitmap();
+ for (byte[] bytes : serialized) {
+ expected.or(RoaringBitmapUtils.deserialize(bytes));
+ }
+ return expected;
+ }
+
+ private static RoaringBitmapUnion fold(DistinctCountBitmapValueAggregator
aggregator, byte[][] serialized) {
+ RoaringBitmapUnion value =
aggregator.getInitialAggregatedValue(serialized[0]);
+ for (int i = 1; i < serialized.length; i++) {
+ value = aggregator.applyRawValue(value, serialized[i]);
+ }
+ return value;
+ }
+
+ @Test
+ public void testApplyRawSerializedBitmaps() {
+ byte[][] serialized = serializedBitmaps(200, 100, 200_000);
+ DistinctCountBitmapValueAggregator aggregator = new
DistinctCountBitmapValueAggregator();
+ assertFalse(aggregator.isAggregatedValueFixedSize());
+ // Nothing has been serialized yet, so no size is known: the star-tree
builders serialize every record before
+ // they consume the size
+ assertEquals(aggregator.getMaxAggregatedValueByteSize(), 0);
+
+ RoaringBitmapUnion value = fold(aggregator, serialized);
+ byte[] result = aggregator.serializeAggregatedValue(value);
+
+ RoaringBitmap expected = eagerUnion(serialized);
+ assertEquals(RoaringBitmapUtils.deserialize(result), expected);
+ assertEquals(value.get().getCardinality(), expected.getCardinality());
+ assertEquals(aggregator.getMaxAggregatedValueByteSize(), result.length);
+
+ // Serializing is non-consuming and repeatable
+ assertEquals(aggregator.serializeAggregatedValue(value), result);
+ assertEquals(aggregator.getMaxAggregatedValueByteSize(), result.length);
+ }
+
+ @Test
+ public void testMaxByteSizeTracksLargestSerializedValue() {
+ DistinctCountBitmapValueAggregator aggregator = new
DistinctCountBitmapValueAggregator();
+ byte[] small = aggregator.serializeAggregatedValue(fold(aggregator,
serializedBitmaps(2, 10, 1_000)));
+ byte[] large = aggregator.serializeAggregatedValue(fold(aggregator,
serializedBitmaps(50, 100, 200_000)));
+ byte[] medium = aggregator.serializeAggregatedValue(fold(aggregator,
serializedBitmaps(5, 100, 200_000)));
+ assertEquals(aggregator.getMaxAggregatedValueByteSize(),
Math.max(small.length, Math.max(large.length,
+ medium.length)));
+ }
+
+ @Test
+ public void testApplyAggregatedValueWithPendingInput() {
+ // Mirrors the on-heap star-tree builder: aggregated records are built
from other records' accumulators via
+ // cloneAggregatedValue + applyAggregatedValue, and every record is only
serialized at the end
+ byte[][] serializedA = serializedBitmaps(100, 100, 150_000);
+ byte[][] serializedB = serializedBitmaps(100, 100, 150_000);
+ DistinctCountBitmapValueAggregator aggregator = new
DistinctCountBitmapValueAggregator();
+
+ RoaringBitmapUnion recordA = fold(aggregator, serializedA);
+ RoaringBitmapUnion recordB = fold(aggregator, serializedB);
+
+ // recordA and recordB both have pending lazy unions here
+ RoaringBitmapUnion aggregated = aggregator.cloneAggregatedValue(recordA);
+ assertNotSame(aggregated, recordA);
+ aggregated = aggregator.applyAggregatedValue(aggregated, recordB);
+
+ RoaringBitmap expected = eagerUnion(serializedA);
+ expected.or(eagerUnion(serializedB));
+
assertEquals(RoaringBitmapUtils.deserialize(aggregator.serializeAggregatedValue(aggregated)),
expected);
+
+ // Serializing is non-consuming: the same accumulator remains usable for
further aggregation, and the value
+ // published by the earlier serialization is not affected
+ RoaringBitmap published = aggregated.get();
+ RoaringBitmap publishedCopy = published.clone();
+ byte[] extra = serializedBitmaps(1, 100, 150_000)[0];
+ aggregated = aggregator.applyRawValue(aggregated, extra);
+ assertEquals(published, publishedCopy);
+ expected.or(RoaringBitmapUtils.deserialize(extra));
+
assertEquals(RoaringBitmapUtils.deserialize(aggregator.serializeAggregatedValue(aggregated)),
expected);
+
+ // The source records must remain intact and serializable
+
assertEquals(RoaringBitmapUtils.deserialize(aggregator.serializeAggregatedValue(recordA)),
+ eagerUnion(serializedA));
+
assertEquals(RoaringBitmapUtils.deserialize(aggregator.serializeAggregatedValue(recordB)),
+ eagerUnion(serializedB));
+ // The clone does not share state with its source
+ recordA = aggregator.applyRawValue(recordA, serializedB[0]);
+
assertEquals(RoaringBitmapUtils.deserialize(aggregator.serializeAggregatedValue(aggregated)),
expected);
+ }
+
+ @Test
+ public void testApplyRawValuesOnly() {
+ DistinctCountBitmapValueAggregator aggregator = new
DistinctCountBitmapValueAggregator();
+ RoaringBitmapUnion value = aggregator.getInitialAggregatedValue(1);
+ for (int i = 2; i <= 1000; i++) {
+ value = aggregator.applyRawValue(value, i);
+ }
+ value = aggregator.applyRawValue(value, new Object[]{1001, 1002, 1002});
+ byte[] result = aggregator.serializeAggregatedValue(value);
+ assertEquals(RoaringBitmapUtils.deserialize(result).getCardinality(),
1002);
+ assertEquals(aggregator.getMaxAggregatedValueByteSize(), result.length);
+ }
+
+ @Test
+ public void testDeserializeRoundTrip() {
+ DistinctCountBitmapValueAggregator aggregator = new
DistinctCountBitmapValueAggregator();
+ byte[][] serialized = serializedBitmaps(20, 100, 50_000);
+ byte[] bytes = aggregator.serializeAggregatedValue(fold(aggregator,
serialized));
+ RoaringBitmapUnion restored = aggregator.deserializeAggregatedValue(bytes);
+ assertEquals(restored.get(), eagerUnion(serialized));
+ assertEquals(aggregator.serializeAggregatedValue(restored), bytes);
+ }
+}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/startree/v2/builder/NullAwareStarTreeBuilderTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/startree/v2/builder/NullAwareStarTreeBuilderTest.java
index 11dd454ad0f..ac018cff381 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/startree/v2/builder/NullAwareStarTreeBuilderTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/startree/v2/builder/NullAwareStarTreeBuilderTest.java
@@ -20,14 +20,20 @@ package org.apache.pinot.segment.local.startree.v2.builder;
import java.io.File;
import java.io.IOException;
+import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
+import java.util.Map;
+import org.apache.commons.configuration2.PropertiesConfiguration;
import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
import
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
import
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
import
org.apache.pinot.segment.local.startree.v2.builder.MultipleTreesBuilder.BuildMode;
import org.apache.pinot.segment.spi.AggregationFunctionType;
+import org.apache.pinot.segment.spi.Constants;
import org.apache.pinot.segment.spi.ImmutableSegment;
import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
@@ -35,6 +41,7 @@ import
org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
import
org.apache.pinot.segment.spi.index.startree.AggregationFunctionColumnPair;
import org.apache.pinot.segment.spi.index.startree.StarTreeV2;
+import org.apache.pinot.spi.config.table.StarTreeAggregationConfig;
import org.apache.pinot.spi.config.table.StarTreeIndexConfig;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
@@ -43,6 +50,7 @@ import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.utils.ReadMode;
import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.roaringbitmap.RoaringBitmap;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.DataProvider;
@@ -77,6 +85,8 @@ public class NullAwareStarTreeBuilderTest {
private static final String MIN_COLUMN =
new AggregationFunctionColumnPair(AggregationFunctionType.MIN,
METRIC).toColumnName();
+ private static final String BITMAP_COLUMN =
+ new
AggregationFunctionColumnPair(AggregationFunctionType.DISTINCTCOUNTBITMAP,
METRIC).toColumnName();
@AfterMethod
public void cleanUp()
@@ -118,6 +128,88 @@ public class NullAwareStarTreeBuilderTest {
}
}
+ /// A bitmap metric is serialized before its forward index is sized, which
the on-heap builder does in a
+ /// pass of its own. An all-null group has nothing to serialize: it must end
up in the null vector while the other
+ /// groups keep their values.
+ @Test(dataProvider = "buildModes")
+ public void
anAllNullGroupSurvivesTheBuildWithAVariableLengthMetric(BuildMode buildMode)
+ throws Exception {
+ File indexDir = createSegment();
+ buildStarTrees(indexDir, buildMode, starTreeConfig(true,
AggregationFunctionType.DISTINCTCOUNTBITMAP));
+
+ ImmutableSegment segment = ImmutableSegmentLoader.load(indexDir,
ReadMode.mmap);
+ try {
+ StarTreeV2 starTree = segment.getStarTrees().get(0);
+ ImmutableRoaringBitmap nullBitmap = nullBitmap(starTree, BITMAP_COLUMN);
+ assertNotNull(nullBitmap, "A null-aware star-tree must write a null
vector for a metric with an all-null group");
+ assertFalse(nullBitmap.isEmpty());
+
+ // Every group that did aggregate something holds some of the two
non-null values and nothing else
+ RoaringBitmap nonNullValues = RoaringBitmap.bitmapOf(10, 20);
+ ForwardIndexReader reader =
starTree.getDataSource(BITMAP_COLUMN).getForwardIndex();
+ assertNotNull(reader);
+ int numAggregatedGroups = 0;
+ try (ForwardIndexReaderContext context = reader.createContext()) {
+ for (int docId = 0; docId < starTree.getMetadata().getNumDocs();
docId++) {
+ if (!nullBitmap.contains(docId)) {
+ RoaringBitmap bitmap =
RoaringBitmapUtils.deserialize(reader.getBytes(docId, context));
+ assertFalse(bitmap.isEmpty());
+ assertTrue(nonNullValues.contains(bitmap));
+ numAggregatedGroups++;
+ }
+ }
+ }
+ assertTrue(numAggregatedGroups > 0, "Some group must have aggregated a
value, or the loop proves nothing");
+ } finally {
+ segment.destroy();
+ }
+ }
+
+ @Test
+ public void onlyBitmapMetricsAreRetainedAsSerializedBytes()
+ throws Exception {
+ File indexDir = createSegment();
+ StarTreeIndexConfig config = new StarTreeIndexConfig(List.of(DIMENSION),
null, List.of(BITMAP_COLUMN), List.of(
+ new StarTreeAggregationConfig(DIMENSION, "SUMPRECISION",
+ Map.of(Constants.SUMPRECISION_PRECISION_KEY, 1000), null, null,
null, null, null),
+ new StarTreeAggregationConfig(METRIC, "SUMPRECISION")),
MAX_LEAF_RECORDS, true);
+ String fixedSumColumn =
+ new
AggregationFunctionColumnPair(AggregationFunctionType.SUMPRECISION,
DIMENSION).toColumnName();
+ String variableSumColumn =
+ new
AggregationFunctionColumnPair(AggregationFunctionType.SUMPRECISION,
METRIC).toColumnName();
+ File outputDir = new File(TEMP_DIR, "mixedMetrics");
+ FileUtils.forceMkdir(outputDir);
+ ImmutableSegment segment = ImmutableSegmentLoader.load(indexDir,
ReadMode.mmap);
+ try (OnHeapSingleTreeBuilder builder = new OnHeapSingleTreeBuilder(
+ StarTreeV2BuilderConfig.fromIndexConfig(config), outputDir, segment,
new PropertiesConfiguration())) {
+ // A complete build also exercises writing pre-serialized bitmaps
alongside ordinary metric values.
+ builder.build();
+ List<String> metrics = Arrays.asList(builder._metrics);
+ int bitmapId = metrics.indexOf(BITMAP_COLUMN);
+ int fixedSumId = metrics.indexOf(fixedSumColumn);
+ int variableSumId = metrics.indexOf(variableSumColumn);
+ int maxBitmapSize = 0;
+ for (int docId = 0; docId < builder._numDocs; docId++) {
+ Object[] values = builder.getStarTreeRecord(docId)._metrics;
+ if (values[bitmapId] != null) {
+ byte[] bytes = (byte[]) values[bitmapId];
+ maxBitmapSize = Math.max(maxBitmapSize, bytes.length);
+ assertEquals(RoaringBitmapUtils.deserialize(bytes),
RoaringBitmap.bitmapOf(10, 20));
+ }
+ assertTrue(values[fixedSumId] instanceof BigDecimal);
+ if (values[variableSumId] != null) {
+ assertEquals(values[variableSumId], BigDecimal.valueOf(30));
+ }
+ }
+ assertTrue(maxBitmapSize > 0);
+
assertEquals(builder._valueAggregators[bitmapId].getMaxAggregatedValueByteSize(),
maxBitmapSize);
+ // A shared zero must not be expanded to a precision-1000 byte array on
every retained record.
+ assertSame(builder.getStarTreeRecord(0)._metrics[fixedSumId],
BigDecimal.ZERO);
+ } finally {
+ segment.destroy();
+ }
+ }
+
/// The two variants answer differently, so a segment has to be able to hold
both: the builder config's identity
/// includes the flag, which is what stops one being reused for the other.
@Test(dataProvider = "buildModes")
@@ -185,8 +277,13 @@ public class NullAwareStarTreeBuilderTest {
}
private static StarTreeIndexConfig starTreeConfig(boolean
nullHandlingEnabled) {
+ return starTreeConfig(nullHandlingEnabled, AggregationFunctionType.MIN);
+ }
+
+ private static StarTreeIndexConfig starTreeConfig(boolean
nullHandlingEnabled,
+ AggregationFunctionType aggregationType) {
return new StarTreeIndexConfig(List.of(DIMENSION), null,
- List.of(new AggregationFunctionColumnPair(AggregationFunctionType.MIN,
METRIC).toColumnName()), null,
+ List.of(new AggregationFunctionColumnPair(aggregationType,
METRIC).toColumnName()), null,
MAX_LEAF_RECORDS, nullHandlingEnabled);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]