This is an automated email from the ASF dual-hosted git repository.
nodece pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git
The following commit(s) were added to refs/heads/master by this push:
new b61f3d5ca28 [improve][broker] Add LongBitmap abstraction and migrate
RoaringBitmap usage to LongBitmap (#26117)
b61f3d5ca28 is described below
commit b61f3d5ca28df13855eaaa40c1e81e9c834eb814
Author: Zixuan Liu <[email protected]>
AuthorDate: Wed Jul 1 09:43:59 2026 +0800
[improve][broker] Add LongBitmap abstraction and migrate RoaringBitmap
usage to LongBitmap (#26117)
---
.../util/collections/LongBitmapBenchmark.java | 141 ++++
.../delayed/InMemoryDelayedDeliveryTracker.java | 59 +-
.../pulsar/broker/delayed/bucket/Bucket.java | 11 +-
.../bucket/BucketDelayedDeliveryTracker.java | 6 +-
.../broker/delayed/bucket/ImmutableBucket.java | 34 +-
.../broker/delayed/bucket/MutableBucket.java | 22 +-
.../broker/service/ConsumerNameIndexTracker.java | 9 +-
.../broker/service/DrainingHashesTracker.java | 27 +-
pulsar-common/build.gradle.kts | 1 +
.../util/collections/ConcurrentRoaringBitmap.java | 441 +++++++++++
.../pulsar/common/util/collections/LongBitmap.java | 170 +++++
.../common/util/collections/LongBitmaps.java | 61 ++
.../collections/LongBitmapCompatibilityTest.java | 235 ++++++
.../common/util/collections/LongBitmapTest.java | 824 +++++++++++++++++++++
14 files changed, 1942 insertions(+), 99 deletions(-)
diff --git
a/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java
b/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java
new file mode 100644
index 00000000000..9fa3c464d73
--- /dev/null
+++
b/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java
@@ -0,0 +1,141 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.roaringbitmap.RoaringBitmap;
+import org.roaringbitmap.longlong.Roaring64Bitmap;
+
+/**
+ * JMH benchmark for {@link LongBitmap} ({@link ConcurrentRoaringBitmap}),
compared against
+ * the pre-PR bitmap implementations it replaces:
+ * <ul>
+ * <li>{@link Roaring64Bitmap} — previously used in {@code
InMemoryDelayedDeliveryTracker}.</li>
+ * <li>{@link RoaringBitmap} — previously used in {@code
ConsumerNameIndexTracker},
+ * {@code DrainingHashesTracker}, and the bucket delayed-delivery
family.</li>
+ * </ul>
+ *
+ * <p>Run with:
+ * <pre>{@code
+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar
LongBitmapBenchmark
+ * }</pre>
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@State(Scope.Benchmark)
+@Warmup(time = 2, iterations = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(time = 3, iterations = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(1)
+public class LongBitmapBenchmark {
+
+ @Param({"1000", "100000"})
+ public int bitmapSize;
+
+ private LongBitmap longBitmap;
+ private RoaringBitmap roaringBitmap;
+ private Roaring64Bitmap roaring64Bitmap;
+
+ private final AtomicLong nextValue = new AtomicLong();
+
+ @Setup(Level.Trial)
+ public void setup() {
+ longBitmap = LongBitmaps.create();
+ roaringBitmap = new RoaringBitmap();
+ roaring64Bitmap = new Roaring64Bitmap();
+ for (int i = 0; i < bitmapSize; i++) {
+ longBitmap.add(i);
+ roaringBitmap.add(i);
+ roaring64Bitmap.addLong(i);
+ }
+ nextValue.set(bitmapSize);
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean longBitmapAddSingleThread() {
+ long v = nextValue.getAndIncrement();
+ return longBitmap.checkedAdd(v);
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean roaringBitmapAddSingleThread() {
+ long v = nextValue.getAndIncrement();
+ return roaringBitmap.checkedAdd((int) v);
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean roaring64BitmapAddSingleThread() {
+ long v = nextValue.getAndIncrement();
+ boolean existed = roaring64Bitmap.contains(v);
+ roaring64Bitmap.addLong(v);
+ return !existed;
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean longBitmapContainsSingleThread() {
+ return longBitmap.contains(nextValue.getAndIncrement() % bitmapSize);
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean roaringBitmapContainsSingleThread() {
+ return roaringBitmap.contains((int) (nextValue.getAndIncrement() %
bitmapSize));
+ }
+
+ @Benchmark
+ @Threads(1)
+ public boolean roaring64BitmapContainsSingleThread() {
+ return roaring64Bitmap.contains(nextValue.getAndIncrement() %
bitmapSize);
+ }
+
+ // Bare RoaringBitmap variants are not thread-safe and are omitted below.
+
+ @Benchmark
+ @Threads(4)
+ public boolean longBitmapAdd4Threads() {
+ long v = nextValue.getAndIncrement();
+ return longBitmap.checkedAdd(v);
+ }
+
+ @Benchmark
+ @Threads(4)
+ public void longBitmapContains4Threads(Blackhole bh) {
+ int v = (int) (Thread.currentThread().getId() * 31 +
System.nanoTime());
+ bh.consume(longBitmap.contains(Math.abs(v) % bitmapSize));
+ }
+}
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java
index c2e1c63b400..b1e50700c72 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java
@@ -36,7 +36,8 @@ import lombok.Getter;
import org.apache.bookkeeper.mledger.Position;
import org.apache.bookkeeper.mledger.PositionFactory;
import
org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers;
-import org.roaringbitmap.longlong.Roaring64Bitmap;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
public class InMemoryDelayedDeliveryTracker extends
AbstractDelayedDeliveryTracker {
@@ -44,8 +45,8 @@ public class InMemoryDelayedDeliveryTracker extends
AbstractDelayedDeliveryTrack
protected final Logger log;
// timestamp -> ledgerId -> entryId
- // AVL tree -> OpenHashMap -> RoaringBitmap
- protected final Long2ObjectSortedMap<Long2ObjectSortedMap<Roaring64Bitmap>>
+ // AVL tree -> OpenHashMap -> LongBitmap
+ protected final Long2ObjectSortedMap<Long2ObjectSortedMap<LongBitmap>>
delayedMessageMap = new Long2ObjectAVLTreeMap<>();
// If we detect that all messages have fixed delay time, such that the
delivery is
@@ -141,14 +142,9 @@ public class InMemoryDelayedDeliveryTracker extends
AbstractDelayedDeliveryTrack
.log("Add message");
long timestamp = roundTimestamp(deliverAt);
- Roaring64Bitmap bitmap = delayedMessageMap.computeIfAbsent(timestamp,
k -> new Long2ObjectRBTreeMap<>())
- .computeIfAbsent(ledgerId, k -> new Roaring64Bitmap());
- // Roaring64Bitmap does not store duplicates, so track if it a new
element
- // so we can keep delayedMessagesCount in sync
- boolean isNew = !bitmap.contains(entryId);
-
- if (isNew) {
- bitmap.addLong(entryId);
+ LongBitmap bitmap = delayedMessageMap.computeIfAbsent(timestamp, k ->
new Long2ObjectRBTreeMap<>())
+ .computeIfAbsent(ledgerId, k -> LongBitmaps.create());
+ if (bitmap.checkedAdd(entryId)) {
delayedMessagesCount.incrementAndGet();
}
@@ -221,28 +217,19 @@ public class InMemoryDelayedDeliveryTracker extends
AbstractDelayedDeliveryTrack
}
LongSet ledgerIdToDelete = new LongOpenHashSet();
- Long2ObjectSortedMap<Roaring64Bitmap> ledgerMap =
delayedMessageMap.get(timestamp);
- for (Long2ObjectMap.Entry<Roaring64Bitmap> ledgerEntry :
ledgerMap.long2ObjectEntrySet()) {
+ Long2ObjectSortedMap<LongBitmap> ledgerMap =
delayedMessageMap.get(timestamp);
+ for (Long2ObjectMap.Entry<LongBitmap> ledgerEntry :
ledgerMap.long2ObjectEntrySet()) {
long ledgerId = ledgerEntry.getLongKey();
- Roaring64Bitmap entryIds = ledgerEntry.getValue();
- long cardinality = entryIds.getLongCardinality();
- if (cardinality <= n) {
- int cardinalityInt = (int) cardinality;
- entryIds.forEach(entryId -> {
- positions.add(PositionFactory.create(ledgerId,
entryId));
- });
- n -= cardinalityInt;
- delayedMessagesCount.addAndGet(-cardinalityInt);
+ LongBitmap entryIds = ledgerEntry.getValue();
+ long cardinality = entryIds.cardinality();
+ long drained = entryIds.drainTo(n, entryId -> {
+ positions.add(PositionFactory.create(ledgerId, entryId));
+ });
+ delayedMessagesCount.addAndGet(-drained);
+ n -= drained;
+ if (drained == cardinality) {
+ // Bitmap is now empty; the entry will be removed from the
ledger map below.
ledgerIdToDelete.add(ledgerId);
- } else {
- Roaring64Bitmap entryIdsToRemove = new Roaring64Bitmap();
- entryIds.stream().limit(n).forEach(entryId -> {
- positions.add(PositionFactory.create(ledgerId,
entryId));
- entryIdsToRemove.addLong(entryId);
- });
- entryIds.andNot(entryIdsToRemove);
- delayedMessagesCount.addAndGet(-n);
- n = 0;
}
if (n <= 0) {
break;
@@ -286,16 +273,14 @@ public class InMemoryDelayedDeliveryTracker extends
AbstractDelayedDeliveryTrack
}
/**
- * This method rely on Roaring64Bitmap::getLongSizeInBytes to calculate
the memory usage of the buffer.
- * The memory usage of the buffer is not accurate, because
Roaring64Bitmap::getLongSizeInBytes will
- * overestimate the memory usage of the buffer a lot.
- * @return the memory usage of the buffer
+ * Estimates memory usage of all bitmaps in the tracker.
+ * Uses serialized size as an approximation of memory usage.
+ * @return estimated memory usage in bytes
*/
@Override
public long getBufferMemoryUsage() {
return delayedMessageMap.values().stream().mapToLong(
- ledgerMap -> ledgerMap.values().stream().mapToLong(
- Roaring64Bitmap::getLongSizeInBytes).sum()).sum();
+ ledgerMap ->
ledgerMap.values().stream().mapToLong(LongBitmap::serializedSize).sum()).sum();
}
@Override
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java
index 2aad122752f..489478df562 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java
@@ -34,7 +34,8 @@ import
org.apache.pulsar.broker.delayed.proto.SnapshotMetadata;
import org.apache.pulsar.broker.delayed.proto.SnapshotSegment;
import org.apache.pulsar.common.util.Codec;
import org.apache.pulsar.common.util.FutureUtil;
-import org.roaringbitmap.RoaringBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
@CustomLog
@Data
@@ -55,7 +56,7 @@ abstract class Bucket {
long startLedgerId;
long endLedgerId;
- Map<Long, RoaringBitmap> delayedIndexBitMap;
+ Map<Long, LongBitmap> delayedIndexBitMap;
long numberBucketDelayedMessages;
@@ -76,7 +77,7 @@ abstract class Bucket {
}
boolean containsMessage(long ledgerId, long entryId) {
- RoaringBitmap bitSet = delayedIndexBitMap.get(ledgerId);
+ LongBitmap bitSet = delayedIndexBitMap.get(ledgerId);
if (bitSet == null) {
return false;
}
@@ -84,12 +85,12 @@ abstract class Bucket {
}
void putIndexBit(long ledgerId, long entryId) {
- delayedIndexBitMap.computeIfAbsent(ledgerId, k -> new
RoaringBitmap()).add(entryId, entryId + 1);
+ delayedIndexBitMap.computeIfAbsent(ledgerId, k ->
LongBitmaps.create()).add(entryId, entryId + 1);
}
boolean removeIndexBit(long ledgerId, long entryId) {
boolean contained = false;
- RoaringBitmap bitSet = delayedIndexBitMap.get(ledgerId);
+ LongBitmap bitSet = delayedIndexBitMap.get(ledgerId);
if (bitSet != null && bitSet.contains(entryId, entryId + 1)) {
contained = true;
bitSet.remove(entryId, entryId + 1);
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java
index 9bfea0604d7..b8a8eb7f0f0 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java
@@ -64,8 +64,8 @@ import org.apache.pulsar.broker.delayed.proto.SnapshotSegment;
import
org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers;
import org.apache.pulsar.common.policies.data.stats.TopicMetricBean;
import org.apache.pulsar.common.util.FutureUtil;
+import org.apache.pulsar.common.util.collections.LongBitmap;
import org.apache.pulsar.common.util.collections.TripleLongPriorityQueue;
-import org.roaringbitmap.RoaringBitmap;
@ThreadSafe
public class BucketDelayedDeliveryTracker extends
AbstractDelayedDeliveryTracker {
@@ -562,7 +562,7 @@ public class BucketDelayedDeliveryTracker extends
AbstractDelayedDeliveryTracker
buckets.get(buckets.size() -
1).endLedgerId);
// Merge bit map to new bucket
- Map<Long, RoaringBitmap> delayedIndexBitMap =
+ Map<Long, LongBitmap> delayedIndexBitMap =
new
HashMap<>(buckets.get(0).getDelayedIndexBitMap());
for (int i = 1; i < buckets.size(); i++) {
buckets.get(i).delayedIndexBitMap.forEach((ledgerId, bitMapB) -> {
@@ -577,8 +577,6 @@ public class BucketDelayedDeliveryTracker extends
AbstractDelayedDeliveryTracker
});
}
- // optimize bm
-
delayedIndexBitMap.values().forEach(RoaringBitmap::runOptimize);
immutableBucketDelayedIndexPair.getLeft().setDelayedIndexBitMap(delayedIndexBitMap);
afterCreateImmutableBucket(immutableBucketDelayedIndexPair, createStartTime);
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java
index e76b225b9d1..2124c6e9540 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java
@@ -20,7 +20,8 @@ package org.apache.pulsar.broker.delayed.bucket;
import static org.apache.bookkeeper.mledger.util.Futures.executeWithRetry;
import static
org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.NULL_LONG_PROMISE;
-import java.io.IOException;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -36,8 +37,8 @@ import org.apache.pulsar.broker.delayed.proto.DelayedIndex;
import org.apache.pulsar.broker.delayed.proto.SnapshotMetadata;
import org.apache.pulsar.broker.delayed.proto.SnapshotSegment;
import org.apache.pulsar.common.util.FutureUtil;
-import org.roaringbitmap.InvalidRoaringFormat;
-import org.roaringbitmap.RoaringBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
@CustomLog
class ImmutableBucket extends Bucket {
@@ -149,7 +150,6 @@ class ImmutableBucket extends Bucket {
/**
* Recover delayed index bit map and message numbers.
- * @throws InvalidRoaringFormat invalid bitmap serialization format
*/
private void recoverDelayedIndexBitMapAndNumber(int startSnapshotIndex,
SnapshotMetadata
snapshotMetadata) {
@@ -157,24 +157,22 @@ class ImmutableBucket extends Bucket {
final var numberMessages = new MutableLong(0);
for (int i = startSnapshotIndex; i <
snapshotMetadata.getMetadataListCount(); i++) {
snapshotMetadata.getMetadataAt(i).forEachDelayedIndexBitMap((ledgerId, bs) -> {
- final var sbm = new RoaringBitmap();
+ final ByteBuf buf = Unpooled.wrappedBuffer(bs);
try {
- sbm.deserialize(java.nio.ByteBuffer.wrap(bs));
- } catch (IOException e) {
- throw new InvalidRoaringFormat(e.getMessage());
+ final LongBitmap sbm = LongBitmaps.deserialize(buf);
+ numberMessages.add(sbm.cardinality());
+ delayedIndexBitMap.compute(ledgerId, (lId, bm) -> {
+ if (bm == null) {
+ return sbm;
+ }
+ bm.or(sbm);
+ return bm;
+ });
+ } finally {
+ buf.release();
}
- numberMessages.add(sbm.getCardinality());
- delayedIndexBitMap.compute(ledgerId, (lId, bm) -> {
- if (bm == null) {
- return sbm;
- }
- bm.or(sbm);
- return bm;
- });
});
}
- // optimize bm
- delayedIndexBitMap.values().forEach(RoaringBitmap::runOptimize);
setNumberBucketDelayedMessages(numberMessages.longValue());
}
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java
index 75a3fad6589..a91016bba0b 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java
@@ -19,7 +19,6 @@
package org.apache.pulsar.broker.delayed.bucket;
import static com.google.common.base.Preconditions.checkArgument;
-import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
@@ -34,8 +33,9 @@ import
org.apache.pulsar.broker.delayed.proto.SnapshotMetadata;
import org.apache.pulsar.broker.delayed.proto.SnapshotSegment;
import org.apache.pulsar.broker.delayed.proto.SnapshotSegmentMetadata;
import org.apache.pulsar.common.util.FutureUtil;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
import org.apache.pulsar.common.util.collections.TripleLongPriorityQueue;
-import org.roaringbitmap.RoaringBitmap;
@CustomLog
class MutableBucket extends Bucket implements AutoCloseable {
@@ -73,9 +73,9 @@ class MutableBucket extends Bucket implements AutoCloseable {
List<SnapshotSegment> bucketSnapshotSegments = new ArrayList<>();
List<SnapshotSegmentMetadata> segmentMetadataList = new ArrayList<>();
- Map<Long, RoaringBitmap> immutableBucketBitMap = new HashMap<>();
+ Map<Long, LongBitmap> immutableBucketBitMap = new HashMap<>();
- Map<Long, RoaringBitmap> bitMap = new HashMap<>();
+ Map<Long, LongBitmap> bitMap = new HashMap<>();
SnapshotSegment snapshotSegment = new SnapshotSegment();
SnapshotSegmentMetadata segmentMetadata = new
SnapshotSegmentMetadata();
@@ -105,7 +105,7 @@ class MutableBucket extends Bucket implements AutoCloseable
{
sharedQueue.add(timestamp, ledgerId, entryId);
}
- bitMap.computeIfAbsent(ledgerId, k -> new
RoaringBitmap()).add(entryId, entryId + 1);
+ bitMap.computeIfAbsent(ledgerId, k ->
LongBitmaps.create()).add(entryId, entryId + 1);
numMessages++;
@@ -116,16 +116,12 @@ class MutableBucket extends Bucket implements
AutoCloseable {
segmentMetadata.setMinScheduleTimestamp(currentFirstTimestamp);
currentTimestampUpperLimit = 0;
- Iterator<Map.Entry<Long, RoaringBitmap>> iterator =
bitMap.entrySet().iterator();
+ Iterator<Map.Entry<Long, LongBitmap>> iterator =
bitMap.entrySet().iterator();
while (iterator.hasNext()) {
final var entry = iterator.next();
final var lId = entry.getKey();
final var bm = entry.getValue();
- bm.runOptimize();
- ByteBuffer byteBuffer =
ByteBuffer.allocate(bm.serializedSizeInBytes());
- bm.serialize(byteBuffer);
- byteBuffer.flip();
- segmentMetadata.putDelayedIndexBitMap(lId,
byteBuffer.array());
+ segmentMetadata.putDelayedIndexBitMap(lId, bm.serialize());
immutableBucketBitMap.compute(lId, (__, bm0) -> {
if (bm0 == null) {
return bm;
@@ -144,10 +140,6 @@ class MutableBucket extends Bucket implements
AutoCloseable {
}
}
- // optimize bm
- immutableBucketBitMap.values().forEach(RoaringBitmap::runOptimize);
- this.delayedIndexBitMap.values().forEach(RoaringBitmap::runOptimize);
-
SnapshotMetadata bucketSnapshotMetadata = new SnapshotMetadata();
for (SnapshotSegmentMetadata sm : segmentMetadataList) {
bucketSnapshotMetadata.addMetadata().copyFrom(sm);
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java
index 1f93313ab1b..b6e5173f7e5 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java
@@ -22,7 +22,8 @@ import java.util.HashMap;
import java.util.Map;
import javax.annotation.concurrent.NotThreadSafe;
import org.apache.commons.lang3.mutable.MutableInt;
-import org.roaringbitmap.RoaringBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
/**
* Tracks the used consumer name indexes for each consumer name.
@@ -56,18 +57,18 @@ class ConsumerNameIndexTracker {
}
/*
- * Tracks the used indexes for a consumer name using a RoaringBitmap.
+ * Tracks the used indexes for a consumer name using a LongBitmap.
* A specific index slot is used when the bit is set.
* When all bits are cleared, the customer name can be removed from
tracking.
*/
static class ConsumerNameIndexSlots {
- private RoaringBitmap indexSlots = new RoaringBitmap();
+ private LongBitmap indexSlots = LongBitmaps.create();
public int allocateIndexSlot() {
// find the first index that is not set, if there is no such
index, add a new one
int index = (int) indexSlots.nextAbsentValue(0);
if (index == -1) {
- index = indexSlots.getCardinality();
+ index = (int) indexSlots.cardinality();
}
indexSlots.add(index);
return index;
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java
index 5393cf4d6a6..87ec1cf1470 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java
@@ -24,7 +24,6 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
-import java.util.PrimitiveIterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -33,7 +32,8 @@ import lombok.ToString;
import org.apache.pulsar.common.policies.data.DrainingHash;
import org.apache.pulsar.common.policies.data.stats.ConsumerStatsImpl;
import org.apache.pulsar.common.policies.data.stats.DrainingHashImpl;
-import org.roaringbitmap.RoaringBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmap;
+import org.apache.pulsar.common.util.collections.LongBitmaps;
/**
* A thread-safe map to store draining hashes in the consumer.
@@ -142,7 +142,7 @@ public class DrainingHashesTracker {
}
private class ConsumerDrainingHashesStats {
- private final RoaringBitmap drainingHashes = new RoaringBitmap();
+ private final LongBitmap drainingHashes = LongBitmaps.create();
private long drainingHashesClearedTotal;
private final ReentrantReadWriteLock statsLock = new
ReentrantReadWriteLock();
@@ -166,12 +166,8 @@ public class DrainingHashesTracker {
.attr("hash", hash)
.attr("empty", empty)
.attr("drainingHashesClearedTotal",
drainingHashesClearedTotal)
- .attr("cardinality", () ->
drainingHashes.getCardinality())
+ .attr("cardinality", () ->
drainingHashes.cardinality())
.log("Cleared hash in stats");
- if (empty) {
- // reduce memory usage by trimming the bitmap when the
RoaringBitmap instance is empty
- drainingHashes.trim();
- }
return empty;
} finally {
statsLock.writeLock().unlock();
@@ -181,11 +177,10 @@ public class DrainingHashesTracker {
public void updateConsumerStats(Consumer consumer, ConsumerStatsImpl
consumerStats) {
statsLock.readLock().lock();
try {
- int drainingHashesUnackedMessages = 0;
List<DrainingHash> drainingHashesStats = new ArrayList<>();
- PrimitiveIterator.OfInt hashIterator =
drainingHashes.stream().iterator();
- while (hashIterator.hasNext()) {
- int hash = hashIterator.nextInt();
+ int[] drainingHashesUnackedMessages = {0};
+ drainingHashes.forEachLong(hashLong -> {
+ int hash = (int) hashLong;
DrainingHashEntry entry = getEntry(hash);
if (entry == null) {
// Not-found entries are expected as a benign race
between the draining-hash
@@ -197,7 +192,7 @@ public class DrainingHashesTracker {
.attr("hash", hash)
.attr("consumer", consumer)
.log("Draining hash not found in the tracker
for consumer");
- continue;
+ return;
}
int unackedMessages = entry.getRefCount();
DrainingHashImpl drainingHash = new DrainingHashImpl();
@@ -205,11 +200,11 @@ public class DrainingHashesTracker {
drainingHash.unackMsgs = unackedMessages;
drainingHash.blockedAttempts = entry.getBlockedCount();
drainingHashesStats.add(drainingHash);
- drainingHashesUnackedMessages += unackedMessages;
- }
+ drainingHashesUnackedMessages[0] += unackedMessages;
+ });
consumerStats.drainingHashesCount = drainingHashesStats.size();
consumerStats.drainingHashesClearedTotal =
drainingHashesClearedTotal;
- consumerStats.drainingHashesUnackedMessages =
drainingHashesUnackedMessages;
+ consumerStats.drainingHashesUnackedMessages =
drainingHashesUnackedMessages[0];
consumerStats.drainingHashes = drainingHashesStats;
} finally {
statsLock.readLock().unlock();
diff --git a/pulsar-common/build.gradle.kts b/pulsar-common/build.gradle.kts
index ad7b37ca3f6..e7b7f28d579 100644
--- a/pulsar-common/build.gradle.kts
+++ b/pulsar-common/build.gradle.kts
@@ -159,6 +159,7 @@ dependencies {
api(libs.netty.handler)
api(libs.netty.buffer)
api(libs.netty.resolver.dns)
+ implementation(libs.roaringbitmap)
implementation(variantOf(libs.netty.transport.native.epoll) {
classifier("linux-x86_64") })
implementation(variantOf(libs.netty.transport.native.epoll) {
classifier("linux-aarch_64") })
implementation(libs.netty.transport.native.unix.common)
diff --git
a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java
new file mode 100644
index 00000000000..7c65441004b
--- /dev/null
+++
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java
@@ -0,0 +1,441 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import io.netty.buffer.ByteBuf;
+import java.io.DataInput;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.locks.StampedLock;
+import java.util.function.LongConsumer;
+import org.roaringbitmap.PeekableIntIterator;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+
+/**
+ * {@link LongBitmap} implementation backed by {@link MutableRoaringBitmap}
and guarded
+ * by a {@link StampedLock}.
+ *
+ * <p><b>Thread-safety basis.</b> RoaringBitmap is not thread-safe by default
+ * (see <a
href="https://github.com/apache/pulsar/issues/25991">pulsar#25991</a>). This
+ * wrapper relies on the documented contract that {@link
MutableRoaringBitmap}'s read
+ * methods — the {@code ImmutableBitmapDataProvider} surface inherited from
+ * {@code ImmutableRoaringBitmap} — do not mutate internal state, while
methods added by
+ * {@code BitmapDataProvider} and other {@code MutableRoaringBitmap} mutators
+ * ({@code andNot}, {@code or}, {@code checkedRemove}, {@code runOptimize},
{@code clone},
+ * ...) do. Read methods run under the read lock; mutators under the write
lock.
+ * {@code clone()} is used under the read lock in {@link #forEachLong} and
{@link #serialize};
+ * its source has been audited to be read-only on the live bitmap. <b>Before
upgrading
+ * the RoaringBitmap dependency or changing the lock split</b>, re-audit these
methods
+ * and run the concurrency regression tests ({@code
testConcurrentForEachLongAndMutate},
+ * {@code testOrDoesNotMutateInput}).
+ *
+ * <p><b>Critical sections.</b> Single-value reads take the read lock;
mutations take the
+ * write lock. Bulk mutations that touch two bitmaps ({@link #or}) acquire
this bitmap's
+ * write lock and the other's read lock in {@code identityHashCode} order, so
concurrent
+ * {@code A.or(B)} and {@code B.or(A)} cannot deadlock. Long non-mutating work
+ * ({@link #serialize}, {@link #forEachLong}) clones under a brief read lock
and finishes
+ * without holding it, so optimize/iterate/runOptimize don't block writers.
+ *
+ * <p><b>Memory.</b> {@link MutableRoaringBitmap#trim()} fires when removals
since the
+ * last trim reach {@link #TRIM_AFTER_REMOVES}, or whenever the bitmap becomes
empty.
+ * {@link #serialize} runs {@code runOptimize()} on the clone so persisted
bytes are compact.
+ */
+class ConcurrentRoaringBitmap implements LongBitmap {
+
+ private static final long TRIM_AFTER_REMOVES = 10000;
+ private static final long UINT32_SIZE = 1L << 32;
+ private static final long MAX_UINT32 = UINT32_SIZE - 1;
+
+ private final MutableRoaringBitmap bitmap;
+ private final StampedLock lock;
+ private long removesSinceTrim;
+
+ ConcurrentRoaringBitmap() {
+ this.bitmap = new MutableRoaringBitmap();
+ this.lock = new StampedLock();
+ }
+
+ private ConcurrentRoaringBitmap(MutableRoaringBitmap bitmap) {
+ this.bitmap = bitmap;
+ this.lock = new StampedLock();
+ }
+
+ @Override
+ public void add(long value) {
+ validateRange(value);
+ long stamp = lock.writeLock();
+ try {
+ bitmap.add((int) value);
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ @Override
+ public boolean checkedAdd(long value) {
+ validateRange(value);
+ long stamp = lock.writeLock();
+ try {
+ return bitmap.checkedAdd((int) value);
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ @Override
+ public void add(long from, long to) {
+ if (to <= from) {
+ return;
+ }
+ validateRange(from);
+ validateRange(to - 1);
+ long stamp = lock.writeLock();
+ try {
+ bitmap.add(from, to);
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ @Override
+ public void remove(long value) {
+ validateRange(value);
+ long stamp = lock.writeLock();
+ try {
+ if (bitmap.checkedRemove((int) value)) {
+ removesSinceTrim++;
+ maybeTrim();
+ }
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ @Override
+ public void remove(long from, long to) {
+ if (to <= from) {
+ return;
+ }
+ validateRange(from);
+ validateRange(to - 1);
+ long stamp = lock.writeLock();
+ try {
+ bitmap.remove(from, to);
+ // Range size upper-bounds removals; clamp so a huge range can't
overflow the counter.
+ removesSinceTrim = Math.min(removesSinceTrim + (to - from),
TRIM_AFTER_REMOVES);
+ maybeTrim();
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ @Override
+ public boolean contains(long value) {
+ if (value < 0 || value > MAX_UINT32) {
+ return false;
+ }
+ long stamp = lock.readLock();
+ try {
+ return bitmap.contains((int) value);
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public boolean contains(long from, long to) {
+ if (from < 0 || from > MAX_UINT32 || to <= from) {
+ return false;
+ }
+ long stamp = lock.readLock();
+ try {
+ // Clamp: contains treats out-of-range `to` as a query past the
uint32 end, but
+ // RoaringBitmap.contains is unreliable when `to` exceeds
UINT32_SIZE.
+ return bitmap.contains(from, Math.min(to, UINT32_SIZE));
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public long cardinality() {
+ long stamp = lock.readLock();
+ try {
+ return bitmap.getLongCardinality();
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public boolean isEmpty() {
+ long stamp = lock.readLock();
+ try {
+ return bitmap.isEmpty();
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public long nextAbsentValue(long from) {
+ if (from < 0 || from > MAX_UINT32) {
+ return -1;
+ }
+ long stamp = lock.readLock();
+ try {
+ return bitmap.nextAbsentValue((int) from);
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public void or(LongBitmap other) {
+ if (other == this) {
+ return;
+ }
+ if (!(other instanceof ConcurrentRoaringBitmap)) {
+ throw new IllegalArgumentException("Unsupported LongBitmap type: "
+ other.getClass());
+ }
+ ConcurrentRoaringBitmap that = (ConcurrentRoaringBitmap) other;
+
+ // Acquire this.writeLock + that.readLock in identityHashCode order so
concurrent
+ // A.or(B) and B.or(A) don't deadlock. Fall back to inner bitmap
identity on collision.
+ boolean thisFirst;
+ int outerCmp = Integer.compare(
+ System.identityHashCode(this), System.identityHashCode(that));
+ if (outerCmp != 0) {
+ thisFirst = outerCmp < 0;
+ } else {
+ thisFirst = System.identityHashCode(this.bitmap) <
System.identityHashCode(that.bitmap);
+ }
+
+ if (thisFirst) {
+ long thisStamp = this.lock.writeLock();
+ try {
+ long thatStamp = that.lock.readLock();
+ try {
+ this.bitmap.or(that.bitmap);
+ } finally {
+ that.lock.unlockRead(thatStamp);
+ }
+ } finally {
+ this.lock.unlockWrite(thisStamp);
+ }
+ } else {
+ long thatStamp = that.lock.readLock();
+ try {
+ long thisStamp = this.lock.writeLock();
+ try {
+ this.bitmap.or(that.bitmap);
+ } finally {
+ this.lock.unlockWrite(thisStamp);
+ }
+ } finally {
+ that.lock.unlockRead(thatStamp);
+ }
+ }
+ }
+
+ @Override
+ public void forEachLong(LongConsumer action) {
+ MutableRoaringBitmap snapshot;
+ long stamp = lock.readLock();
+ try {
+ snapshot = bitmap.clone();
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ snapshot.forEach((org.roaringbitmap.IntConsumer) v ->
+ action.accept(Integer.toUnsignedLong(v)));
+ }
+
+ @Override
+ public long drainTo(long limit, LongConsumer action) {
+ if (limit <= 0) {
+ return 0;
+ }
+ MutableRoaringBitmap toRemove = new MutableRoaringBitmap();
+ long collected;
+ long writeStamp = lock.writeLock();
+ try {
+ PeekableIntIterator it = bitmap.getIntIterator();
+ collected = 0;
+ while (collected < limit && it.hasNext()) {
+ toRemove.add(it.next());
+ collected++;
+ }
+ if (collected == 0) {
+ return 0;
+ }
+ bitmap.andNot(toRemove);
+ removesSinceTrim = Math.min(removesSinceTrim + collected,
TRIM_AFTER_REMOVES);
+ maybeTrim();
+ } finally {
+ lock.unlockWrite(writeStamp);
+ }
+
+ toRemove.forEach((org.roaringbitmap.IntConsumer) v ->
+ action.accept(Integer.toUnsignedLong(v)));
+ return collected;
+ }
+
+ @Override
+ public long serializedSize() {
+ long stamp = lock.readLock();
+ try {
+ return bitmap.serializedSizeInBytes();
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Override
+ public byte[] serialize() {
+ MutableRoaringBitmap copy;
+ long stamp = lock.readLock();
+ try {
+ copy = bitmap.clone();
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ copy.runOptimize();
+ byte[] bytes = new byte[copy.serializedSizeInBytes()];
+ copy.serialize(ByteBuffer.wrap(bytes));
+ return bytes;
+ }
+
+ static ConcurrentRoaringBitmap deserialize(ByteBuf buf) {
+ try {
+ ByteBuffer nioBuffer = buf.nioBuffer(buf.readerIndex(),
buf.readableBytes());
+ int startPosition = nioBuffer.position();
+ MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+ bitmap.deserialize(new ByteBufferDataInput(nioBuffer));
+ buf.skipBytes(nioBuffer.position() - startPosition);
+ return new ConcurrentRoaringBitmap(bitmap);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to deserialize LongBitmap", e);
+ }
+ }
+
+ /**
+ * Trims the underlying bitmap if enough removals have accumulated or it's
empty.
+ * Caller must hold the write lock and have already updated {@link
#removesSinceTrim}.
+ */
+ private void maybeTrim() {
+ if (removesSinceTrim >= TRIM_AFTER_REMOVES || bitmap.isEmpty()) {
+ bitmap.trim();
+ removesSinceTrim = 0;
+ }
+ }
+
+ private static void validateRange(long value) {
+ if (value < 0 || value > MAX_UINT32) {
+ throw new IllegalArgumentException(
+ "Value out of range [0, " + MAX_UINT32 + "]: " + value);
+ }
+ }
+
+ /** Minimal {@link DataInput} over a {@link ByteBuffer} for RoaringBitmap
deserialization. */
+ private static final class ByteBufferDataInput implements DataInput {
+ private final ByteBuffer buffer;
+
+ ByteBufferDataInput(ByteBuffer buffer) {
+ this.buffer = buffer;
+ }
+
+ @Override
+ public void readFully(byte[] b) {
+ buffer.get(b);
+ }
+
+ @Override
+ public void readFully(byte[] b, int off, int len) {
+ buffer.get(b, off, len);
+ }
+
+ @Override
+ public int skipBytes(int n) {
+ int skip = Math.min(n, buffer.remaining());
+ buffer.position(buffer.position() + skip);
+ return skip;
+ }
+
+ @Override
+ public boolean readBoolean() {
+ return buffer.get() != 0;
+ }
+
+ @Override
+ public byte readByte() {
+ return buffer.get();
+ }
+
+ @Override
+ public int readUnsignedByte() {
+ return Byte.toUnsignedInt(buffer.get());
+ }
+
+ @Override
+ public short readShort() {
+ return buffer.getShort();
+ }
+
+ @Override
+ public int readUnsignedShort() {
+ return Short.toUnsignedInt(buffer.getShort());
+ }
+
+ @Override
+ public char readChar() {
+ return buffer.getChar();
+ }
+
+ @Override
+ public int readInt() {
+ return buffer.getInt();
+ }
+
+ @Override
+ public long readLong() {
+ return buffer.getLong();
+ }
+
+ @Override
+ public float readFloat() {
+ return buffer.getFloat();
+ }
+
+ @Override
+ public double readDouble() {
+ return buffer.getDouble();
+ }
+
+ @Override
+ public String readLine() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String readUTF() {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git
a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java
new file mode 100644
index 00000000000..5176da8c125
--- /dev/null
+++
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java
@@ -0,0 +1,170 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import java.util.function.LongConsumer;
+
+/**
+ * Thread-safe bitmap abstraction for tracking long values.
+ *
+ * <p>The current implementation supports values in the unsigned 32-bit range
+ * {@code [0, 2^32 - 1]}. Methods that modify the bitmap reject values outside
this
+ * range with {@link IllegalArgumentException}. Query methods return {@code
false}
+ * or {@code -1} for out-of-range values where applicable.
+ *
+ * <p>Supports point and range operations, bulk union, atomic draining,
iteration,
+ * and serialization. All operations are thread-safe.
+ *
+ * <p>This abstraction is used for high-throughput broker metadata tracking,
+ * including delayed-delivery tracking, consumer-name allocation, and
+ * draining-hash tracking.
+ *
+ * <p>Example:
+ * <pre>{@code
+ * LongBitmap bitmap = LongBitmaps.create();
+ * bitmap.add(12345L);
+ * if (bitmap.contains(12345L)) { ... }
+ *
+ * byte[] bytes = bitmap.serialize();
+ * LongBitmap restored =
LongBitmaps.deserialize(Unpooled.wrappedBuffer(bytes));
+ * }</pre>
+ */
+public interface LongBitmap {
+
+ /**
+ * Adds a value.
+ *
+ * @param value value to add, must be in {@code [0, 2^32 - 1]}
+ * @throws IllegalArgumentException if value is outside the supported range
+ */
+ void add(long value);
+
+ /**
+ * Adds a value if it is not already present.
+ *
+ * <p>This operation is atomic. Unlike {@code if (!contains(value))
add(value)},
+ * the check and add are performed as a single operation.
+ *
+ * @param value value to add, must be in {@code [0, 2^32 - 1]}
+ * @return {@code true} if the value was added, {@code false} if it
already existed
+ * @throws IllegalArgumentException if value is outside the supported range
+ */
+ boolean checkedAdd(long value);
+
+ /**
+ * Adds all values in the half-open range {@code [from, to)}.
+ *
+ * <p>No-op if {@code to <= from}.
+ *
+ * @param from inclusive lower bound
+ * @param to exclusive upper bound
+ * @throws IllegalArgumentException if the range exceeds the supported
value range
+ */
+ void add(long from, long to);
+
+ /**
+ * Removes a value. No-op if absent.
+ *
+ * @param value value to remove
+ * @throws IllegalArgumentException if value is outside the supported range
+ */
+ void remove(long value);
+
+ /**
+ * Removes all values in the half-open range {@code [from, to)}.
+ *
+ * <p>No-op if {@code to <= from}.
+ *
+ * @param from inclusive lower bound
+ * @param to exclusive upper bound
+ * @throws IllegalArgumentException if the range exceeds the supported
value range
+ */
+ void remove(long from, long to);
+
+ /**
+ * Returns whether the bitmap contains the given value.
+ *
+ * @param value value to check
+ * @return {@code true} if present, otherwise {@code false}
+ */
+ boolean contains(long value);
+
+ /**
+ * Returns whether all values in {@code [from, to)} are present.
+ *
+ * @param from inclusive lower bound
+ * @param to exclusive upper bound
+ * @return {@code true} if all values in the range are present
+ */
+ boolean contains(long from, long to);
+
+ /** Returns the number of values currently stored. */
+ long cardinality();
+
+ /** Returns {@code true} if no values are stored. */
+ boolean isEmpty();
+
+ /**
+ * Returns the smallest absent value greater than or equal to {@code from}.
+ *
+ * @param from inclusive lower bound
+ * @return next absent value, or {@code -1} if none exists
+ */
+ long nextAbsentValue(long from);
+
+ /**
+ * Adds all values from {@code other} into this bitmap.
+ *
+ * @param other bitmap to merge
+ */
+ void or(LongBitmap other);
+
+ /**
+ * Iterates values in ascending order.
+ *
+ * <p>The iteration observes a stable view of the bitmap. Implementations
may
+ * choose the mechanism used to provide this guarantee.
+ *
+ * @param action callback invoked for each value
+ */
+ void forEachLong(LongConsumer action);
+
+ /**
+ * Atomically removes up to {@code limit} values and invokes {@code action}
+ * for each removed value.
+ *
+ * <p>Selection and removal are performed atomically. The callback is
invoked
+ * after removal has completed.
+ *
+ * @param limit maximum number of values to drain
+ * @param action callback invoked for each removed value
+ * @return number of values drained
+ */
+ long drainTo(long limit, LongConsumer action);
+
+ /**
+ * Returns an upper bound of the serialized size.
+ */
+ long serializedSize();
+
+ /**
+ * Serializes the bitmap into a newly allocated byte array.
+ */
+ byte[] serialize();
+}
diff --git
a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java
new file mode 100644
index 00000000000..489502a439d
--- /dev/null
+++
b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java
@@ -0,0 +1,61 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import io.netty.buffer.ByteBuf;
+
+/**
+ * Factory for creating {@link LongBitmap} instances.
+ */
+public final class LongBitmaps {
+
+ private LongBitmaps() {
+ // utility class
+ }
+
+ /**
+ * Creates a new empty thread-safe LongBitmap.
+ *
+ * @return a new LongBitmap instance
+ */
+ public static LongBitmap create() {
+ return new ConcurrentRoaringBitmap();
+ }
+
+ /**
+ * Deserializes a LongBitmap from a ByteBuf.
+ *
+ * <p>Advances the buffer's {@code readerIndex} by the number of bytes
consumed. The
+ * buffer may be heap-backed, direct, or a {@link
io.netty.buffer.CompositeByteBuf} —
+ * the implementation reads via {@link ByteBuf#nioBuffer(int, int)}
without copying
+ * when possible.
+ *
+ * <p>The serialized format is the standard 32-bit RoaringBitmap portable
format, so
+ * buffers produced by {@link LongBitmap#serialize()} round-trip exactly.
Buffers in
+ * other formats (e.g. {@code Roaring64Bitmap}) are rejected.
+ *
+ * @param buf the input buffer positioned at the start of the serialized
bitmap
+ * @return the deserialized LongBitmap
+ * @throws RuntimeException if the buffer is malformed, truncated, or in an
+ * unrecognized format (wraps the underlying {@link
java.io.IOException})
+ */
+ public static LongBitmap deserialize(ByteBuf buf) {
+ return ConcurrentRoaringBitmap.deserialize(buf);
+ }
+}
diff --git
a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java
b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java
new file mode 100644
index 00000000000..6c9081db44f
--- /dev/null
+++
b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.nio.ByteBuffer;
+import org.roaringbitmap.RoaringBitmap;
+import org.roaringbitmap.longlong.Roaring64Bitmap;
+import org.testng.annotations.Test;
+
+/**
+ * Verifies the serialization compatibility characteristics of {@link
LongBitmap}.
+ *
+ * <p>Context: the previous implementation used two different RoaringBitmap
variants:
+ * <ul>
+ * <li>{@code InMemoryDelayedDeliveryTracker} used {@link Roaring64Bitmap}
(in-memory only)
+ * <li>{@code BucketDelayedDeliveryTracker} used {@link RoaringBitmap}
(32-bit, persisted)
+ * </ul>
+ *
+ * <p>The new {@link LongBitmap} abstraction always uses the 32-bit {@link
RoaringBitmap}
+ * internally. These tests document and verify:
+ * <ol>
+ * <li>32-bit buffer compatibility: {@link LongBitmap} buffers are
byte-identical to standard
+ * {@link RoaringBitmap} buffers (required for {@code
BucketDelayedDeliveryTracker}
+ * backward compatibility — old persisted snapshots must round-trip).
+ * <li>64-bit buffer incompatibility: {@link Roaring64Bitmap} buffers cannot
be deserialized
+ * by {@link LongBitmap} (format includes high-32-bit bucket prefixes).
Migration from
+ * {@code Roaring64Bitmap} is safe only for in-memory state; persisted
state would require
+ * a one-time entry-by-entry rebuild.
+ * <li>Behavioral equivalence within {@code uint32} range: {@link
LongBitmap} and
+ * {@link Roaring64Bitmap} produce identical add/remove/contains results
for all
+ * values in {@code [0, 2^32)}.
+ * </ol>
+ */
+public class LongBitmapCompatibilityTest {
+
+ /**
+ * LongBitmap's serialized bytes must be identical to a 32-bit
RoaringBitmap's
+ * for the same uint32 values. This guarantees persisted snapshots written
by
+ * the old {@code BucketDelayedDeliveryTracker} (using RoaringBitmap)
remain
+ * readable, and vice versa.
+ */
+ @Test
+ public void testLongBitmapBufferEqualsStandardRoaringBitmap() throws
Exception {
+ LongBitmap longBitmap = LongBitmaps.create();
+ longBitmap.add(0);
+ longBitmap.add(100);
+ longBitmap.add(1L << 20);
+ longBitmap.add(0xFFFFFFFFL); // uint32 max
+
+ RoaringBitmap roaring = new RoaringBitmap();
+ roaring.add(0);
+ roaring.add(100);
+ roaring.add(1 << 20);
+ roaring.add(0xFFFFFFFF); // -1 as signed int
+
+ byte[] longBitmapBytes = serializeLongBitmap(longBitmap);
+ byte[] roaringBytes = serializeRoaring32(roaring);
+
+ assertEquals(longBitmapBytes, roaringBytes,
+ "LongBitmap buffer must be byte-identical to standard 32-bit
RoaringBitmap buffer");
+ }
+
+ /**
+ * Round-trip: 32-bit RoaringBitmap buffer -> LongBitmap. Confirms that
+ * persisted data written by the old code path can be read by LongBitmap.
+ */
+ @Test
+ public void testDeserializeFromRoaring32Buffer() throws Exception {
+ RoaringBitmap roaring = new RoaringBitmap();
+ for (int i = 0; i < 1000; i += 7) {
+ roaring.add(i);
+ }
+ roaring.add(0xFFFFFFFF);
+
+ byte[] roaringBytes = serializeRoaring32(roaring);
+ ByteBuf buf = Unpooled.wrappedBuffer(roaringBytes);
+ try {
+ LongBitmap longBitmap = LongBitmaps.deserialize(buf);
+ assertEquals(longBitmap.cardinality(),
roaring.getLongCardinality());
+ assertTrue(longBitmap.contains(0xFFFFFFFFL));
+ for (int i = 0; i < 1000; i += 7) {
+ assertTrue(longBitmap.contains(i), "missing " + i);
+ }
+ for (int i = 1; i < 1000; i += 7) {
+ assertFalse(longBitmap.contains(i));
+ }
+ } finally {
+ buf.release();
+ }
+ }
+
+ /**
+ * LongBitmap buffer cannot be deserialized as a Roaring64Bitmap.
+ * The 32-bit RoaringBitmap portable format (cookie 12346, ~22 bytes for
small sets)
+ * is shorter than Roaring64Bitmap's bucketed header, so {@code
Roaring64Bitmap.deserialize}
+ * throws {@link java.nio.BufferUnderflowException}.
+ *
+ * <p>Migration implication: persisted 32-bit data cannot be read by old
code paths
+ * that still expect Roaring64Bitmap, and vice versa.
+ */
+ @Test
+ public void testLongBitmapBufferNotReadableAsRoaring64() throws Exception {
+ LongBitmap longBitmap = LongBitmaps.create();
+ longBitmap.add(1);
+ longBitmap.add(100);
+ longBitmap.add(1000);
+
+ byte[] longBitmapBytes = serializeLongBitmap(longBitmap);
+
+ Roaring64Bitmap roaring64 = new Roaring64Bitmap();
+ // 32-bit format is shorter than 64-bit header expects — buffer
underflow or related I/O error.
+ assertThrows(Exception.class,
+ () -> roaring64.deserialize(ByteBuffer.wrap(longBitmapBytes)));
+ }
+
+ /**
+ * Roaring64Bitmap buffer cannot be deserialized as a LongBitmap.
+ * Roaring64Bitmap's serialized format starts with a bucket count, not the
+ * 32-bit cookie (12346), so {@code MutableRoaringBitmap.deserialize}
throws.
+ */
+ @Test
+ public void testRoaring64BufferNotReadableAsLongBitmap() throws Exception {
+ Roaring64Bitmap roaring64 = new Roaring64Bitmap();
+ roaring64.addLong(1);
+ roaring64.addLong(100);
+ roaring64.addLong(1000);
+
+ byte[] roaring64Bytes = serializeRoaring64(roaring64);
+
+ ByteBuf buf = Unpooled.wrappedBuffer(roaring64Bytes);
+ try {
+ // MutableRoaringBitmap wraps IOException as RuntimeException.
+ assertThrows(Exception.class, () -> LongBitmaps.deserialize(buf));
+ } finally {
+ buf.release();
+ }
+ }
+
+ /**
+ * Behavioral equivalence within uint32 range: LongBitmap and
Roaring64Bitmap
+ * produce identical results for all operations on values in [0, 2^32).
+ * This is what makes the InMemoryDelayedDeliveryTracker migration safe —
+ * BookKeeper entry IDs are currently always < 2^32.
+ */
+ @Test
+ public void testBehavioralEquivalenceWithinUint32() {
+ LongBitmap longBitmap = LongBitmaps.create();
+ Roaring64Bitmap roaring64 = new Roaring64Bitmap();
+
+ long[] values = {0, 1, 100, 65535, 65536, 1L << 20, 1L << 30,
0xFFFFFFFFL};
+ for (long v : values) {
+ longBitmap.add(v);
+ roaring64.addLong(v);
+ }
+
+ assertEquals(longBitmap.cardinality(), roaring64.getLongCardinality());
+ for (long v : values) {
+ assertTrue(longBitmap.contains(v));
+ assertTrue(roaring64.contains(v));
+ }
+
+ long[] toRemove = {0, 100, 65536, 1L << 30};
+ for (long v : toRemove) {
+ longBitmap.remove(v);
+ roaring64.removeLong(v);
+ }
+
+ assertEquals(longBitmap.cardinality(), roaring64.getLongCardinality());
+ for (long v : toRemove) {
+ assertFalse(longBitmap.contains(v));
+ assertFalse(roaring64.contains(v));
+ }
+ }
+
+ /**
+ * LongBitmap accepts the uint32 boundary (2^32 - 1) but rejects 2^32 and
above.
+ * Migration from Roaring64Bitmap must ensure no values >= 2^32 are
present;
+ * otherwise the migration would silently drop or reject those entries.
+ */
+ @Test
+ public void testUint32Boundary() {
+ LongBitmap longBitmap = LongBitmaps.create();
+ longBitmap.add(0xFFFFFFFFL);
+ assertTrue(longBitmap.contains(0xFFFFFFFFL));
+ assertEquals(longBitmap.cardinality(), 1);
+
+ assertThrows(IllegalArgumentException.class, () ->
longBitmap.add(0x100000000L));
+ assertThrows(IllegalArgumentException.class, () -> longBitmap.add(-1));
+ }
+
+ private static byte[] serializeLongBitmap(LongBitmap bitmap) {
+ return bitmap.serialize();
+ }
+
+ private static byte[] serializeRoaring32(RoaringBitmap bitmap) throws
Exception {
+ bitmap.runOptimize();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(baos);
+ bitmap.serialize(dos);
+ dos.close();
+ return baos.toByteArray();
+ }
+
+ private static byte[] serializeRoaring64(Roaring64Bitmap bitmap) throws
Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(baos);
+ bitmap.serialize(dos);
+ dos.close();
+ return baos.toByteArray();
+ }
+}
diff --git
a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java
b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java
new file mode 100644
index 00000000000..35bbf9b5f57
--- /dev/null
+++
b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java
@@ -0,0 +1,824 @@
+/*
+ * 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.pulsar.common.util.collections;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link LongBitmap}. Covers point/range operations, uint32
boundary
+ * behavior, serialization round-trip, drain-to atomicity, and concurrency
contracts
+ * (or lock ordering, snapshot safety under mutation, or-input immutability).
+ */
+public class LongBitmapTest {
+
+ @Test
+ public void testBasicOperations() {
+ LongBitmap bitmap = LongBitmaps.create();
+
+ assertEquals(bitmap.cardinality(), 0);
+ assertFalse(bitmap.contains(1));
+
+ bitmap.add(1);
+ bitmap.add(100);
+ bitmap.add(1000);
+
+ assertEquals(bitmap.cardinality(), 3);
+ assertTrue(bitmap.contains(1));
+ assertTrue(bitmap.contains(100));
+ assertTrue(bitmap.contains(1000));
+ assertFalse(bitmap.contains(2));
+
+ bitmap.remove(100);
+ assertEquals(bitmap.cardinality(), 2);
+ assertFalse(bitmap.contains(100));
+ }
+
+ @Test
+ public void testRangeValidation() {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(0);
+ bitmap.add(0xFFFFFFFFL);
+ assertTrue(bitmap.contains(0));
+ assertTrue(bitmap.contains(0xFFFFFFFFL));
+
+ assertThrows(IllegalArgumentException.class, () -> bitmap.add(-1));
+ assertThrows(IllegalArgumentException.class, () ->
bitmap.add(0x100000000L));
+ assertFalse(bitmap.contains(-1));
+ assertFalse(bitmap.contains(0x100000000L));
+ }
+
+ @Test
+ public void testSerialization() {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 1000; i += 10) {
+ bitmap.add(i);
+ }
+
+ byte[] bytes = bitmap.serialize();
+ assertTrue(bytes.length > 0);
+
+ LongBitmap deserialized =
LongBitmaps.deserialize(Unpooled.wrappedBuffer(bytes));
+ assertEquals(deserialized.cardinality(), 100);
+ for (int i = 0; i < 1000; i += 10) {
+ assertTrue(deserialized.contains(i));
+ }
+ assertFalse(deserialized.contains(5));
+ }
+
+ @Test
+ public void testDeserializeFromVariousByteBufTypes() {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 2000; i += 7) {
+ bitmap.add(i);
+ }
+ byte[] bytes = bitmap.serialize();
+
+ ByteBuf heap = Unpooled.wrappedBuffer(bytes);
+ try {
+ LongBitmap r = LongBitmaps.deserialize(heap);
+ assertEquals(r.cardinality(), bitmap.cardinality());
+ assertEquals(heap.readerIndex(), bytes.length);
+ } finally {
+ heap.release();
+ }
+
+ ByteBuf direct = Unpooled.directBuffer(bytes.length);
+ try {
+ direct.writeBytes(bytes);
+ LongBitmap r = LongBitmaps.deserialize(direct);
+ assertEquals(r.cardinality(), bitmap.cardinality());
+ assertEquals(direct.readerIndex(), bytes.length);
+ } finally {
+ direct.release();
+ }
+
+ ByteBuf singleComp = Unpooled.wrappedBuffer(new
ByteBuf[]{Unpooled.wrappedBuffer(bytes)});
+ try {
+ LongBitmap r = LongBitmaps.deserialize(singleComp);
+ assertEquals(r.cardinality(), bitmap.cardinality());
+ assertEquals(singleComp.readerIndex(), bytes.length);
+ } finally {
+ singleComp.release();
+ }
+
+ int split = bytes.length / 2;
+ ByteBuf part1 = Unpooled.wrappedBuffer(bytes, 0, split);
+ ByteBuf part2 = Unpooled.wrappedBuffer(bytes, split, bytes.length -
split);
+ ByteBuf composite = Unpooled.wrappedBuffer(part1, part2);
+ try {
+ LongBitmap r = LongBitmaps.deserialize(composite);
+ assertEquals(r.cardinality(), bitmap.cardinality());
+ assertEquals(composite.readerIndex(), bytes.length);
+ } finally {
+ composite.release();
+ }
+ }
+
+ @Test
+ public void testAddIsIdempotent() {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(42);
+ assertEquals(bitmap.cardinality(), 1);
+ assertTrue(bitmap.contains(42));
+
+ bitmap.add(42);
+ bitmap.add(42);
+ assertEquals(bitmap.cardinality(), 1);
+ assertTrue(bitmap.contains(42));
+ }
+
+ @Test
+ public void testCheckedAdd() {
+ LongBitmap bitmap = LongBitmaps.create();
+
+ assertTrue(bitmap.checkedAdd(42));
+ assertTrue(bitmap.contains(42));
+ assertEquals(bitmap.cardinality(), 1);
+
+ assertFalse(bitmap.checkedAdd(42));
+ assertEquals(bitmap.cardinality(), 1);
+
+ assertTrue(bitmap.checkedAdd(100));
+ assertEquals(bitmap.cardinality(), 2);
+
+ assertThrows(IllegalArgumentException.class, () ->
bitmap.checkedAdd(-1));
+ assertThrows(IllegalArgumentException.class, () ->
bitmap.checkedAdd(0x100000000L));
+ assertEquals(bitmap.cardinality(), 2);
+ }
+
+ @Test
+ public void testForEach() {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(1);
+ bitmap.add(5);
+ bitmap.add(10);
+
+ List<Long> values = new ArrayList<>();
+ bitmap.forEachLong(values::add);
+
+ assertEquals(values.size(), 3);
+ Collections.sort(values);
+ assertEquals(values.get(0).longValue(), 1);
+ assertEquals(values.get(1).longValue(), 5);
+ assertEquals(values.get(2).longValue(), 10);
+ }
+
+ @Test
+ public void testConcurrentReads() throws Exception {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 10000; i++) {
+ bitmap.add(i);
+ }
+
+ int numThreads = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+ CountDownLatch latch = new CountDownLatch(numThreads);
+ AtomicInteger errors = new AtomicInteger(0);
+
+ for (int i = 0; i < numThreads; i++) {
+ executor.submit(() -> {
+ try {
+ for (int j = 0; j < 1000; j++) {
+ if (!bitmap.contains(j)) {
+ errors.incrementAndGet();
+ }
+ if (bitmap.cardinality() != 10000) {
+ errors.incrementAndGet();
+ }
+ }
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+ assertEquals(errors.get(), 0);
+ }
+
+ @Test
+ public void testConcurrentWrites() throws Exception {
+ LongBitmap bitmap = LongBitmaps.create();
+
+ int numThreads = 10;
+ int valuesPerThread = 1000;
+ ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+ CountDownLatch latch = new CountDownLatch(numThreads);
+
+ for (int t = 0; t < numThreads; t++) {
+ int threadId = t;
+ executor.submit(() -> {
+ try {
+ for (int i = 0; i < valuesPerThread; i++) {
+ bitmap.add(threadId * valuesPerThread + i);
+ }
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+
+ assertEquals(bitmap.cardinality(), numThreads * valuesPerThread);
+ for (int t = 0; t < numThreads; t++) {
+ for (int i = 0; i < valuesPerThread; i++) {
+ assertTrue(bitmap.contains(t * valuesPerThread + i));
+ }
+ }
+ }
+
+ @Test
+ public void testConcurrentReadWrite() throws Exception {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 5000; i++) {
+ bitmap.add(i);
+ }
+
+ int numReaders = 5;
+ int numWriters = 5;
+ ExecutorService executor = Executors.newFixedThreadPool(numReaders +
numWriters);
+ CountDownLatch latch = new CountDownLatch(numReaders + numWriters);
+ AtomicInteger errors = new AtomicInteger(0);
+
+ for (int i = 0; i < numReaders; i++) {
+ executor.submit(() -> {
+ try {
+ for (int j = 0; j < 1000; j++) {
+ bitmap.contains(j % 5000);
+ bitmap.cardinality();
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+ for (int i = 0; i < numWriters; i++) {
+ int writerId = i;
+ executor.submit(() -> {
+ try {
+ for (int j = 0; j < 1000; j++) {
+ bitmap.add(5000 + writerId * 1000 + j);
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+ assertEquals(errors.get(), 0);
+ }
+
+ @Test
+ public void testConcurrentForEachLongAndMutate() throws Exception {
+ // forEachLong takes a clone() snapshot under read lock; concurrent
mutations on the
+ // live bitmap must never corrupt the snapshot. Regression guard for
pulsar#25991.
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 10000; i++) {
+ bitmap.add(i);
+ }
+
+ int numReaders = 5;
+ int numWriters = 5;
+ ExecutorService executor = Executors.newFixedThreadPool(numReaders +
numWriters);
+ CountDownLatch latch = new CountDownLatch(numReaders + numWriters);
+ AtomicInteger errors = new AtomicInteger(0);
+
+ for (int i = 0; i < numReaders; i++) {
+ executor.submit(() -> {
+ try {
+ for (int j = 0; j < 100; j++) {
+ long[] last = {-1};
+ bitmap.forEachLong(v -> {
+ // Snapshot values must arrive in ascending order.
+ if (v <= last[0]) {
+ errors.incrementAndGet();
+ }
+ last[0] = v;
+ });
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ for (int i = 0; i < numWriters; i++) {
+ int id = i;
+ executor.submit(() -> {
+ try {
+ for (int j = 0; j < 1000; j++) {
+ long v = 10000 + id * 1000 + (j % 1000);
+ bitmap.add(v);
+ bitmap.remove(v);
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+ assertEquals(errors.get(), 0);
+ assertEquals(bitmap.cardinality(), 10000);
+ }
+
+ @Test
+ public void testMemoryTrim() {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 100000; i++) {
+ bitmap.add(i);
+ }
+ for (int i = 0; i < 50000; i++) {
+ bitmap.remove(i);
+ }
+
+ assertEquals(bitmap.cardinality(), 50000);
+ for (int i = 0; i < 50000; i++) {
+ assertFalse(bitmap.contains(i));
+ }
+ for (int i = 50000; i < 100000; i++) {
+ assertTrue(bitmap.contains(i));
+ }
+ }
+
+ @Test
+ public void testIsEmpty() {
+ LongBitmap bitmap = LongBitmaps.create();
+ assertTrue(bitmap.isEmpty());
+
+ bitmap.add(1);
+ assertFalse(bitmap.isEmpty());
+
+ bitmap.remove(1);
+ assertTrue(bitmap.isEmpty());
+
+ bitmap.remove(999); // never added
+ assertTrue(bitmap.isEmpty());
+ }
+
+ @Test
+ public void testOr() {
+ LongBitmap a = LongBitmaps.create();
+ a.add(1);
+ a.add(100);
+ a.add(1000);
+
+ LongBitmap b = LongBitmaps.create();
+ b.add(100); // overlap
+ b.add(2000); // unique to b
+
+ a.or(b);
+
+ assertEquals(a.cardinality(), 4);
+ assertTrue(a.contains(1));
+ assertTrue(a.contains(100));
+ assertTrue(a.contains(1000));
+ assertTrue(a.contains(2000));
+
+ // b is not modified
+ assertEquals(b.cardinality(), 2);
+ }
+
+ @Test
+ public void testOrEmpty() {
+ LongBitmap a = LongBitmaps.create();
+ a.add(1);
+ a.add(2);
+
+ LongBitmap empty = LongBitmaps.create();
+ a.or(empty);
+ assertEquals(a.cardinality(), 2);
+
+ LongBitmap target = LongBitmaps.create();
+ target.or(a);
+ assertEquals(target.cardinality(), 2);
+ }
+
+ @Test
+ public void testOrSelfIsNoOp() {
+ LongBitmap a = LongBitmaps.create();
+ a.add(1);
+ a.add(100);
+
+ a.or(a); // should not deadlock
+
+ assertEquals(a.cardinality(), 2);
+ assertTrue(a.contains(1));
+ assertTrue(a.contains(100));
+ }
+
+ @Test
+ public void testRangeAddRemoveContains() {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(100, 200);
+ assertEquals(bitmap.cardinality(), 100);
+ for (long v = 100; v < 200; v++) {
+ assertTrue(bitmap.contains(v));
+ }
+ assertFalse(bitmap.contains(99));
+ assertFalse(bitmap.contains(200));
+
+ // Single-value range [x, x+1) is equivalent to add(x).
+ LongBitmap single = LongBitmaps.create();
+ single.add(42, 43);
+ assertTrue(single.contains(42, 43));
+ assertTrue(single.contains(42));
+ assertEquals(single.cardinality(), 1);
+
+ // Range contains: true iff EVERY value in [from, to) is set.
+ assertTrue(bitmap.contains(100, 200));
+ assertTrue(bitmap.contains(150, 160));
+ assertFalse(bitmap.contains(99, 101));
+ assertFalse(bitmap.contains(199, 201));
+ assertFalse(bitmap.contains(200, 300));
+
+ bitmap.remove(100, 150);
+ assertEquals(bitmap.cardinality(), 50);
+ for (long v = 100; v < 150; v++) {
+ assertFalse(bitmap.contains(v));
+ }
+ for (long v = 150; v < 200; v++) {
+ assertTrue(bitmap.contains(v));
+ }
+ }
+
+ @Test
+ public void testRangeVsSingleValueEquivalence() {
+ // For any v, add(v, v+1) / contains(v, v+1) / remove(v, v+1) ≡ add(v)
/ contains(v) / remove(v).
+ long[] values = {0, 1, 100, 65535, 65536, 1L << 30, 0xFFFFFFFFL};
+ for (long v : values) {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(v);
+ assertTrue(bitmap.contains(v));
+ assertEquals(bitmap.cardinality(), 1);
+
+ bitmap.add(v); // idempotent
+ assertEquals(bitmap.cardinality(), 1);
+
+ bitmap.remove(v);
+ assertFalse(bitmap.contains(v));
+ assertEquals(bitmap.cardinality(), 0);
+ }
+ }
+
+ @Test
+ public void testNextAbsentValue() {
+ LongBitmap bitmap = LongBitmaps.create();
+ // Empty bitmap: first absent is 0
+ assertEquals(bitmap.nextAbsentValue(0), 0);
+
+ bitmap.add(0);
+ bitmap.add(1);
+ bitmap.add(2);
+ // [0,1,2] present, next absent from 0 is 3
+ assertEquals(bitmap.nextAbsentValue(0), 3);
+
+ bitmap.add(5);
+ // Gap at 3,4
+ assertEquals(bitmap.nextAbsentValue(0), 3);
+ assertEquals(bitmap.nextAbsentValue(3), 3);
+ assertEquals(bitmap.nextAbsentValue(5), 6);
+
+ // Out of range returns -1
+ assertEquals(bitmap.nextAbsentValue(-1), -1);
+ assertEquals(bitmap.nextAbsentValue(0x100000000L), -1);
+ }
+
+ @Test
+ public void testDrainTo() {
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 100; i++) {
+ bitmap.add(i * 10); // 0, 10, 20, ..., 990
+ }
+
+ List<Long> drained = new ArrayList<>();
+ long count = bitmap.drainTo(5, drained::add);
+
+ assertEquals(count, 5);
+ assertEquals(drained.size(), 5);
+ assertEquals(drained.get(0).longValue(), 0);
+ assertEquals(drained.get(4).longValue(), 40);
+ assertEquals(bitmap.cardinality(), 95);
+ assertFalse(bitmap.contains(0));
+ assertFalse(bitmap.contains(40));
+ assertTrue(bitmap.contains(50));
+
+ drained.clear();
+ count = bitmap.drainTo(1000, drained::add); // drain all remaining
+ assertEquals(count, 95);
+ assertTrue(bitmap.isEmpty());
+ }
+
+ @Test
+ public void testDrainToZeroOrNegative() {
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(1);
+ bitmap.add(2);
+
+ assertEquals(bitmap.drainTo(0, v -> {
+ }), 0);
+ assertEquals(bitmap.cardinality(), 2);
+
+ assertEquals(bitmap.drainTo(-1, v -> {
+ }), 0);
+ assertEquals(bitmap.cardinality(), 2);
+ }
+
+ @Test
+ public void testEmptyRangeIsNoOp() {
+ // add(x, x) and remove(x, x) must be no-ops, not throw.
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(1);
+ bitmap.add(2);
+
+ bitmap.add(5, 5);
+ bitmap.add(10, 1); // reversed range
+ assertEquals(bitmap.cardinality(), 2);
+
+ bitmap.remove(5, 5);
+ bitmap.remove(10, 1);
+ assertEquals(bitmap.cardinality(), 2);
+ }
+
+ @Test
+ public void testDrainToFollowedByDrainToDoesNotLeak() {
+ // drainTo must respect the same trim threshold as remove(value).
+ // Add many values, drain in small batches; if trim never fires, the
bitmap
+ // accumulates unused container capacity across calls.
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 50000; i++) {
+ bitmap.add(i);
+ }
+
+ // Drain 100 at a time — well under TRIM_AFTER_REMOVES (10000) per
call,
+ // but cumulative trim counter should cross the threshold after
several batches.
+ long totalDrained = 0;
+ while (!bitmap.isEmpty()) {
+ totalDrained += bitmap.drainTo(100, v -> {
+ });
+ }
+ assertEquals(totalDrained, 50000);
+ assertTrue(bitmap.isEmpty());
+ }
+
+ @Test
+ public void testOrDoesNotMutateInput() {
+ // A.or(B) must treat B as read-only — required for our lock split
+ // (this=writeLock, other=readLock).
+ LongBitmap a = LongBitmaps.create();
+ LongBitmap b = LongBitmaps.create();
+ for (int i = 0; i < 1000; i++) {
+ a.add(i * 2);
+ b.add(i * 2 + 1);
+ }
+ long bCardinalityBefore = b.cardinality();
+
+ a.or(b);
+
+ assertEquals(b.cardinality(), bCardinalityBefore);
+ for (int i = 0; i < 1000; i++) {
+ assertTrue(b.contains(i * 2 + 1), "b lost value after a.or(b)");
+ assertFalse(b.contains(i * 2), "b gained value after a.or(b)");
+ }
+ assertEquals(a.cardinality(), 2000);
+ }
+
+ @Test
+ public void testOrCrossDirectionNoDeadlock() throws Exception {
+ // Concurrent A.or(B) and B.or(A) must not deadlock.
+ // Lock ordering by identityHashCode prevents the classic AB-BA
deadlock.
+ int pairs = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(4);
+ CountDownLatch latch = new CountDownLatch(pairs * 2);
+ AtomicInteger errors = new AtomicInteger(0);
+
+ for (int i = 0; i < pairs; i++) {
+ final LongBitmap a = LongBitmaps.create();
+ final LongBitmap b = LongBitmaps.create();
+ for (int j = 0; j < 100; j++) {
+ a.add(j);
+ b.add(j + 50); // overlap
+ }
+ executor.submit(() -> {
+ try {
+ a.or(b);
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ executor.submit(() -> {
+ try {
+ b.or(a);
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS),
+ "or() cross-direction should not deadlock");
+ executor.shutdown();
+ assertEquals(errors.get(), 0);
+ }
+
+ @Test
+ public void testDrainToActionDoesNotHoldWriteLock() {
+ // Verify that the action runs outside the lock: a slow action should
not block
+ // concurrent operations. The select+remove phase holds writeLock
briefly, but
+ // the action invocation happens after unlock.
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 1000; i++) {
+ bitmap.add(i);
+ }
+
+ // Slow action: sleep briefly per value. drainTo should not hold any
lock
+ // during action execution — concurrent operations should remain
responsive.
+ long start = System.nanoTime();
+ long drained = bitmap.drainTo(100, v -> {
+ try {
+ Thread.sleep(1);
+ } catch (InterruptedException e) {
+ }
+ });
+ long elapsed = System.nanoTime() - start;
+ assertEquals(drained, 100);
+ // Total elapsed ~100ms (sleep); the lock was released before action
ran.
+ assertTrue(elapsed >= 100_000_000L); // sanity: drainTo ran the
action 100 times
+ }
+
+ @Test
+ public void testUint32BoundaryRange() {
+ // Verify range APIs handle the uint32 upper boundary correctly.
+ // add(MAX_UINT32, MAX_UINT32+1) should add exactly one value:
MAX_UINT32.
+ // Note: MutableRoaringBitmap.contains(long, long) has a known issue
at this
+ // boundary where it returns false even when the value is present, so
we only
+ // test contains(long) single-value form and cardinality.
+ LongBitmap bitmap = LongBitmaps.create();
+ bitmap.add(0xFFFFFFFFL, 0x100000000L);
+ assertEquals(bitmap.cardinality(), 1);
+ assertTrue(bitmap.contains(0xFFFFFFFFL));
+
+ bitmap.remove(0xFFFFFFFFL, 0x100000000L);
+ assertEquals(bitmap.cardinality(), 0);
+ assertFalse(bitmap.contains(0xFFFFFFFFL));
+ }
+
+ @Test
+ public void testRangeReachesMaxUint32WithoutClamp() {
+ // Validates that add/remove do not need Math.min(to, UINT32_SIZE):
+ // validateRange(to - 1) already guarantees to <= UINT32_SIZE, and
RoaringBitmap
+ // handles to == UINT32_SIZE correctly at the boundary. If this test
ever fails,
+ // the clamp must be restored.
+ LongBitmap bitmap = LongBitmaps.create();
+
+ // Multi-value range that ends exactly at UINT32_SIZE: [MAX-1, MAX+1)
= {MAX-1, MAX}
+ bitmap.add(0xFFFFFFFEL, 0x100000000L);
+ assertEquals(bitmap.cardinality(), 2);
+ assertTrue(bitmap.contains(0xFFFFFFFEL));
+ assertTrue(bitmap.contains(0xFFFFFFFFL));
+
+ // Same range on remove
+ bitmap.remove(0xFFFFFFFEL, 0x100000000L);
+ assertEquals(bitmap.cardinality(), 0);
+ assertFalse(bitmap.contains(0xFFFFFFFEL));
+ assertFalse(bitmap.contains(0xFFFFFFFFL));
+
+ // Crossing container boundary (65535/65536) with to on a power-of-2
boundary
+ bitmap.add(65530L, 65540L);
+ assertEquals(bitmap.cardinality(), 10);
+ bitmap.remove(65530L, 65540L);
+ assertEquals(bitmap.cardinality(), 0);
+
+ // Out-of-range `to` must throw — proving validateRange guards the
upper bound.
+ assertThrows(IllegalArgumentException.class,
+ () -> bitmap.add(0L, 0x100000001L)); // to = UINT32_SIZE + 1
+ }
+
+ @Test
+ public void testSerializedSizeIsUpperBoundForSerialize() {
+ // serializedSize() is an upper bound (no runOptimize). serialize()
runs
+ // runOptimize, which only shrinks. So estimated >= actual.
+ long[] seeds = {0, 100, 65535, 65536, 1L << 30, 0xFFFFFF00L};
+ for (int trial = 0; trial < 5; trial++) {
+ LongBitmap bitmap = LongBitmaps.create();
+ java.util.Random rng = new java.util.Random(trial);
+ int count = 1000 + rng.nextInt(5000);
+ for (int i = 0; i < count; i++) {
+ bitmap.add(seeds[rng.nextInt(seeds.length)] +
rng.nextInt(100));
+ }
+ long estimated = bitmap.serializedSize();
+ byte[] actual = bitmap.serialize();
+ assertTrue(estimated >= actual.length,
+ "trial " + trial + ": estimated " + estimated + " < actual
" + actual.length);
+ }
+ }
+
+ @Test
+ public void testDrainToIsAtomic() {
+ // drainTo must atomically select+remove values. Concurrent add/remove
should
+ // not cause newly added values to be lost between snapshot and
removal.
+ LongBitmap bitmap = LongBitmaps.create();
+ for (int i = 0; i < 100; i++) {
+ bitmap.add(i);
+ }
+
+ // Drain with a slow action, while concurrently adding/removing values
+ AtomicInteger errors = new AtomicInteger(0);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ CountDownLatch latch = new CountDownLatch(2);
+
+ // T1: drain slowly
+ executor.submit(() -> {
+ try {
+ bitmap.drainTo(50, v -> {
+ try {
+ Thread.sleep(1);
+ } catch (InterruptedException e) {
+ }
+ });
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+
+ // T2: add/remove while T1 drains
+ executor.submit(() -> {
+ try {
+ Thread.sleep(10); // let T1 start draining
+ for (int i = 0; i < 50; i++) {
+ bitmap.remove(i);
+ bitmap.add(i);
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+
+ try {
+ assertTrue(latch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+ assertEquals(errors.get(), 0);
+ // After drain(50) and concurrent add/remove, the bitmap should
contain
+ // the values that were re-added by T2, not lost due to racy
andNot.
+ long finalCount = bitmap.cardinality();
+ // We drained ~50, then T2 added back some of those. Final count
>= 50
+ // (the un-drained values) is the key invariant.
+ assertTrue(finalCount >= 50,
+ "finalCount=" + finalCount + " should be >= 50 (un-drained
values)");
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}