diff --git a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerBenchmark.java b/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerBenchmark.java
index 08d02195c8781..a8c1019f02215 100644
--- a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerBenchmark.java
+++ b/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerBenchmark.java
@@ -53,15 +53,18 @@
*
This benchmark measures tracker throughput under different read/write ratios
* and initial message counts without implying a specific lock implementation.
*
- *
Run with: mvn exec:java -Dexec.mainClass="org.openjdk.jmh.Main"
- * -Dexec.args="BucketDelayedDeliveryTrackerBenchmark"
+ *
Run with:
+ *
+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar BucketDelayedDeliveryTrackerBenchmark
+ *
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark)
-@Warmup(time = 10, timeUnit = TimeUnit.SECONDS, iterations = 1)
-@Measurement(time = 10, timeUnit = TimeUnit.SECONDS, iterations = 1)
-@Fork(1)
+@Warmup(time = 10, timeUnit = TimeUnit.SECONDS, iterations = 3)
+@Measurement(time = 10, timeUnit = TimeUnit.SECONDS, iterations = 5)
+@Fork(2)
public class BucketDelayedDeliveryTrackerBenchmark {
/**
diff --git a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSealBenchmark.java b/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSealBenchmark.java
new file mode 100644
index 0000000000000..0131f9e7675be
--- /dev/null
+++ b/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSealBenchmark.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.broker.delayed.bucket;
+
+import io.netty.util.HashedWheelTimer;
+import io.netty.util.Timer;
+import io.netty.util.concurrent.DefaultThreadFactory;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.apache.bookkeeper.mledger.PositionFactory;
+import org.apache.bookkeeper.mledger.impl.ActiveManagedCursorContainerImpl;
+import org.apache.bookkeeper.mledger.impl.MockManagedCursor;
+import org.apache.pulsar.broker.delayed.NoopDelayedDeliveryContext;
+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.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures the latency of the add that rolls a full mutable bucket to a new ledger.
+ *
+ * The invocation setup preloads the full bucket outside the measured interval. The direct mode is a
+ * same-code blocking baseline; the async mode measures the seal-and-swap path used by the broker factory.
+ *
+ *
Run with:
+ *
+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar \
+ * BucketDelayedDeliveryTrackerSealBenchmark
+ *
+ */
+@BenchmarkMode(Mode.SingleShotTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Warmup(iterations = 3)
+@Measurement(iterations = 5)
+@Fork(2)
+@State(Scope.Thread)
+public class BucketDelayedDeliveryTrackerSealBenchmark {
+
+ private static final long FUTURE_DELIVERY_BASE_TIME_MILLIS = 4102444800000L;
+
+ @Param({"10000", "100000", "1000000"})
+ public int indexesPerBucket;
+
+ @Param({"direct", "async"})
+ public String buildMode;
+
+ private BucketDelayedDeliveryTracker tracker;
+ private Timer timer;
+ private MockBucketSnapshotStorage storage;
+ private ExecutorService snapshotBuildExecutor;
+
+ @Setup(Level.Invocation)
+ public void setupInvocation() throws Exception {
+ timer = new HashedWheelTimer(new DefaultThreadFactory("seal-rollover-timer"),
+ 100, TimeUnit.MILLISECONDS);
+ storage = new MockBucketSnapshotStorage();
+ storage.start();
+
+ ActiveManagedCursorContainerImpl container = new ActiveManagedCursorContainerImpl();
+ MockManagedCursor cursor = MockManagedCursor.createCursor(container, "seal-rollover-cursor",
+ PositionFactory.create(0, 0));
+ String dispatcherName = "persistent://public/default/seal-rollover / " + cursor.getName();
+ NoopDelayedDeliveryContext context = new NoopDelayedDeliveryContext(dispatcherName, cursor);
+
+ Executor executor;
+ snapshotBuildExecutor = null;
+ if ("async".equals(buildMode)) {
+ snapshotBuildExecutor = Executors.newSingleThreadExecutor(
+ new DefaultThreadFactory("seal-rollover-snapshot-builder"));
+ executor = snapshotBuildExecutor;
+ } else {
+ executor = Runnable::run;
+ }
+ tracker = new BucketDelayedDeliveryTracker(context, timer, 1_000, Clock.systemUTC(), true, storage,
+ indexesPerBucket, TimeUnit.MINUTES.toMillis(5), 5_000, -1, executor);
+
+ for (int entryId = 0; entryId < indexesPerBucket; entryId++) {
+ tracker.addMessage(1, entryId, FUTURE_DELIVERY_BASE_TIME_MILLIS + entryId);
+ }
+ }
+
+ @TearDown(Level.Invocation)
+ public void tearDownInvocation() throws Exception {
+ if (tracker != null) {
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ while (tracker.getBucketsCount().get() == 0 && System.nanoTime() < deadlineNanos) {
+ Thread.sleep(1);
+ }
+ if (tracker.getBucketsCount().get() != 1) {
+ throw new IllegalStateException("The rollover snapshot did not commit");
+ }
+ tracker.close();
+ }
+ if (snapshotBuildExecutor != null) {
+ snapshotBuildExecutor.shutdown();
+ if (!snapshotBuildExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
+ snapshotBuildExecutor.shutdownNow();
+ }
+ }
+ if (storage != null) {
+ storage.close();
+ }
+ if (timer != null) {
+ timer.stop();
+ }
+ }
+
+ @Benchmark
+ public boolean sealRolloverAddLatency() {
+ return tracker.addMessage(2, 0, FUTURE_DELIVERY_BASE_TIME_MILLIS);
+ }
+}
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/BucketDelayedDeliveryTrackerFactory.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/BucketDelayedDeliveryTrackerFactory.java
index 80d1076c1c8f4..dcc4e1c7c2035 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/BucketDelayedDeliveryTrackerFactory.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/BucketDelayedDeliveryTrackerFactory.java
@@ -25,7 +25,11 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import lombok.CustomLog;
import org.apache.bookkeeper.mledger.ManagedCursor;
@@ -39,6 +43,7 @@
import org.apache.pulsar.broker.service.BrokerService;
import org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers;
import org.apache.pulsar.common.util.FutureUtil;
+import org.apache.pulsar.common.util.GracefulExecutorServicesShutdown;
@CustomLog
public class BucketDelayedDeliveryTrackerFactory implements DelayedDeliveryTrackerFactory {
@@ -58,6 +63,8 @@ public class BucketDelayedDeliveryTrackerFactory implements DelayedDeliveryTrack
private int delayedDeliveryMaxIndexesPerBucketSnapshotSegment;
+ private ExecutorService snapshotBuildExecutor;
+
@Override
public void initialize(PulsarService pulsarService) throws Exception {
ServiceConfiguration config = pulsarService.getConfig();
@@ -73,6 +80,14 @@ public void initialize(PulsarService pulsarService) throws Exception {
config.getDelayedDeliveryMaxTimeStepPerBucketSnapshotSegmentSeconds();
this.delayedDeliveryMaxIndexesPerBucketSnapshotSegment =
config.getDelayedDeliveryMaxIndexesPerBucketSnapshotSegment();
+ int snapshotBuildThreads = Math.max(1, config.getNumExecutorThreadPoolSize());
+ // Don't queue detached buckets: they retain off-heap indexes and temporarily delay their delivery.
+ // Saturated submissions are rejected so the tracker can immediately fall back to shared memory mode.
+ BlockingQueue workQueue = new SynchronousQueue<>();
+ this.snapshotBuildExecutor = new ThreadPoolExecutor(snapshotBuildThreads, snapshotBuildThreads,
+ 0L, TimeUnit.MILLISECONDS, workQueue,
+ new DefaultThreadFactory("pulsar-delayed-delivery-snapshot-builder"),
+ new ThreadPoolExecutor.AbortPolicy());
}
@Override
@@ -104,7 +119,8 @@ BucketDelayedDeliveryTracker newTracker0(AbstractPersistentDispatcherMultipleCon
return new BucketDelayedDeliveryTracker(dispatcher, timer, tickTimeMillis,
isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, delayedDeliveryMinIndexCountPerBucket,
TimeUnit.SECONDS.toMillis(delayedDeliveryMaxTimeStepPerBucketSnapshotSegmentSeconds),
- delayedDeliveryMaxIndexesPerBucketSnapshotSegment, delayedDeliveryMaxNumBuckets);
+ delayedDeliveryMaxIndexesPerBucketSnapshotSegment, delayedDeliveryMaxNumBuckets,
+ snapshotBuildExecutor);
}
/**
@@ -133,6 +149,9 @@ public CompletableFuture cleanResidualSnapshots(ManagedCursor cursor) {
@Override
public void close() throws Exception {
+ if (snapshotBuildExecutor != null) {
+ GracefulExecutorServicesShutdown.initiate().shutdown(snapshotBuildExecutor).handle().join();
+ }
if (bucketSnapshotStorage != null) {
bucketSnapshotStorage.close();
}
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 bd7c596561cc5..55871b3fefb1b 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
@@ -42,6 +42,8 @@
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;
@@ -88,6 +90,8 @@ public static record SnapshotKey(long ledgerId, long entryId) {}
private static final int MAX_MERGE_NUM = 4;
+ private static final Executor DIRECT_EXECUTOR = Runnable::run;
+
private final long minIndexCountPerBucket;
private final long timeStepPerBucketSnapshotSegmentInMillis;
@@ -102,7 +106,7 @@ public static record SnapshotKey(long ledgerId, long entryId) {}
@Getter
@VisibleForTesting
- private final MutableBucket lastMutableBucket;
+ private volatile MutableBucket lastMutableBucket;
@Getter
@VisibleForTesting
@@ -128,6 +132,22 @@ public static record SnapshotKey(long ledgerId, long entryId) {}
private final BucketDelayedMessageIndexStats stats;
+ private final Executor snapshotBuildExecutor;
+
+ private final AtomicLong pendingSealMemoryUsage = new AtomicLong();
+
+ private final AtomicLong pendingSealMessageCount = new AtomicLong();
+
+ private PendingSeal pendingSeal;
+
+ private long lifecycleGeneration;
+
+ private Lifecycle lifecycle = Lifecycle.OPEN;
+
+ private CompletableFuture clearFuture;
+
+ private CompletableFuture closeFuture;
+
private CompletableFuture pendingLoad = null;
private volatile CompletableFuture trimFuture;
@@ -141,7 +161,22 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer
throws RecoverDelayedDeliveryTrackerException {
this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, Clock.systemUTC(),
isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket,
- timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets);
+ timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets,
+ DIRECT_EXECUTOR);
+ }
+
+ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher,
+ Timer timer, long tickTimeMillis,
+ boolean isDelayedDeliveryDeliverAtTimeStrict,
+ BucketSnapshotStorage bucketSnapshotStorage,
+ long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis,
+ int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets,
+ Executor snapshotBuildExecutor)
+ throws RecoverDelayedDeliveryTrackerException {
+ this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, Clock.systemUTC(),
+ isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket,
+ timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets,
+ snapshotBuildExecutor);
}
public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher,
@@ -153,7 +188,23 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer
throws RecoverDelayedDeliveryTrackerException {
this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, clock,
isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket,
- timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets);
+ timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets,
+ DIRECT_EXECUTOR);
+ }
+
+ @VisibleForTesting
+ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher,
+ Timer timer, long tickTimeMillis, Clock clock,
+ boolean isDelayedDeliveryDeliverAtTimeStrict,
+ BucketSnapshotStorage bucketSnapshotStorage,
+ long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis,
+ int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets,
+ Executor snapshotBuildExecutor)
+ throws RecoverDelayedDeliveryTrackerException {
+ this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, clock,
+ isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket,
+ timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets,
+ snapshotBuildExecutor);
}
@VisibleForTesting
@@ -164,6 +215,20 @@ public BucketDelayedDeliveryTracker(DelayedDeliveryContext context,
long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis,
int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets)
throws RecoverDelayedDeliveryTrackerException {
+ this(context, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage,
+ minIndexCountPerBucket, timeStepPerBucketSnapshotSegmentInMillis,
+ maxIndexesPerBucketSnapshotSegment, maxNumBuckets, DIRECT_EXECUTOR);
+ }
+
+ @VisibleForTesting
+ public BucketDelayedDeliveryTracker(DelayedDeliveryContext context,
+ Timer timer, long tickTimeMillis, Clock clock,
+ boolean isDelayedDeliveryDeliverAtTimeStrict,
+ BucketSnapshotStorage bucketSnapshotStorage,
+ long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis,
+ int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets,
+ Executor snapshotBuildExecutor)
+ throws RecoverDelayedDeliveryTrackerException {
super(context, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict);
this.log = LOG.with().ctx(super.log).build();
this.minIndexCountPerBucket = minIndexCountPerBucket;
@@ -177,6 +242,7 @@ public BucketDelayedDeliveryTracker(DelayedDeliveryContext context,
bucketSnapshotStorage);
this.lastMutableBucket = new MutableBucket(ctx);
this.stats = new BucketDelayedMessageIndexStats();
+ this.snapshotBuildExecutor = snapshotBuildExecutor;
// Close the tracker if failed to recover.
try {
@@ -187,6 +253,17 @@ public BucketDelayedDeliveryTracker(DelayedDeliveryContext context,
}
}
+ private enum Lifecycle {
+ OPEN,
+ CLEARING,
+ CLOSING,
+ CLOSED
+ }
+
+ private record PendingSeal(long generation, MutableBucket bucket, long nextDeliveryTime,
+ long startTime, CompletableFuture completion) {
+ }
+
private synchronized long recoverBucketSnapshot() throws RecoverDelayedDeliveryTrackerException {
ManagedCursor cursor = ctx.cursor();
Map cursorProperties = cursor.getCursorProperties();
@@ -329,7 +406,7 @@ private synchronized void putAndCleanOverlapRange(Range range, ImmutableBu
@Override
public void run(Timeout timeout) throws Exception {
synchronized (this) {
- if (timeout == null || timeout.isCancelled()) {
+ if (lifecycle != Lifecycle.OPEN || timeout == null || timeout.isCancelled()) {
return;
}
lastMutableBucket.moveScheduledMessageToSharedQueue(getCutoffTime(), sharedBucketPriorityQueue);
@@ -382,10 +459,12 @@ private void afterCreateImmutableBucket(Pair immu
// Put indexes back into the shared queue and downgrade to memory mode
synchronized (BucketDelayedDeliveryTracker.this) {
immutableBucket.getSnapshotSegments().ifPresent(snapshotSegments -> {
- for (SnapshotSegment snapshotSegment : snapshotSegments) {
- for (DelayedIndex delayedIndex : snapshotSegment.getIndexesList()) {
- sharedBucketPriorityQueue.add(delayedIndex.getTimestamp(),
- delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ if (lifecycle == Lifecycle.OPEN) {
+ for (SnapshotSegment snapshotSegment : snapshotSegments) {
+ for (DelayedIndex delayedIndex : snapshotSegment.getIndexesList()) {
+ sharedBucketPriorityQueue.add(delayedIndex.getTimestamp(),
+ delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ }
}
}
immutableBucket.setSnapshotSegments(null);
@@ -405,62 +484,245 @@ private void afterCreateImmutableBucket(Pair immu
}
@Override
- public synchronized boolean addMessage(long ledgerId, long entryId, long deliverAt) {
- if (deliverAt < 0 || deliverAt <= getCutoffTime()) {
- removeIndexBit(ledgerId, entryId);
- return false;
- }
+ public boolean addMessage(long ledgerId, long entryId, long deliverAt) {
+ PendingSeal sealToSubmit = null;
+ synchronized (this) {
+ if (lifecycle != Lifecycle.OPEN) {
+ return false;
+ }
- if (containsMessage(ledgerId, entryId)) {
- return true;
- }
+ if (deliverAt < 0 || deliverAt <= getCutoffTime()) {
+ removeIndexBit(ledgerId, entryId);
+ return false;
+ }
- boolean existBucket = findImmutableBucket(ledgerId) != null;
-
- // Create bucket snapshot
- if (!existBucket && ledgerId > lastMutableBucket.endLedgerId
- && lastMutableBucket.size() >= minIndexCountPerBucket
- && !lastMutableBucket.isEmpty()) {
- long createStartTime = System.currentTimeMillis();
- stats.recordTriggerEvent(BucketDelayedMessageIndexStats.Type.create);
- Pair immutableBucketDelayedIndexPair =
- lastMutableBucket.sealBucketAndAsyncPersistent(
- this.timeStepPerBucketSnapshotSegmentInMillis,
- this.maxIndexesPerBucketSnapshotSegment,
- this.sharedBucketPriorityQueue);
- afterCreateImmutableBucket(immutableBucketDelayedIndexPair, createStartTime);
- lastMutableBucket.resetLastMutableBucketRange();
-
- if (maxNumBuckets > 0 && bucketsCount.get() > maxNumBuckets
- && (trimFuture == null || trimFuture.isDone())) {
- trimFuture = asyncTrimImmutableBuckets()
- .thenCompose(ignore -> asyncMergeBucketSnapshot())
- .whenComplete((ignore, t) -> {
- if (t != null) {
- log.warn().exception(t).log("Failed to trim or merge bucket snapshots");
- }
- });
+ if (index.contains(ledgerId, entryId)) {
+ return true;
}
- }
- if (ledgerId >= lastMutableBucket.endLedgerId && !existBucket) {
- lastMutableBucket.addMessage(ledgerId, entryId, deliverAt);
- } else {
- // Message index belongs to previous bucket range or the current mutable bucket range,
- // enter sharedBucketPriorityQueue directly
- sharedBucketPriorityQueue.add(deliverAt, ledgerId, entryId);
- }
- index.track(ledgerId, entryId);
+ boolean existBucket = findImmutableBucket(ledgerId) != null;
+ if (pendingSeal == null && !existBucket && ledgerId > lastMutableBucket.endLedgerId
+ && lastMutableBucket.size() >= minIndexCountPerBucket
+ && !lastMutableBucket.isEmpty()) {
+ MutableBucket detachedBucket = lastMutableBucket;
+ lastMutableBucket = new MutableBucket(ctx);
+ sealToSubmit = new PendingSeal(lifecycleGeneration, detachedBucket,
+ detachedBucket.nextDeliveryTime(),
+ System.currentTimeMillis(), new CompletableFuture<>());
+ pendingSeal = sealToSubmit;
+ pendingSealMemoryUsage.set(detachedBucket.getBufferMemoryUsage());
+ pendingSealMessageCount.set(detachedBucket.size());
+ stats.recordTriggerEvent(BucketDelayedMessageIndexStats.Type.create);
+ }
+
+ if (ledgerId >= lastMutableBucket.endLedgerId && !existBucket) {
+ lastMutableBucket.addMessage(ledgerId, entryId, deliverAt);
+ } else {
+ // Message index belongs to a previous bucket range, enter the shared queue directly.
+ // BucketDelayedMessageIndex remains the runtime source of truth while a seal is pending.
+ sharedBucketPriorityQueue.add(deliverAt, ledgerId, entryId);
+ }
+ index.track(ledgerId, entryId);
log.debug()
.attr("ledgerId", ledgerId)
.attr("entryId", entryId)
.attr("deliveryInMs", deliverAt - clock.millis())
.log("Add message");
- updateTimer();
+ updateTimer();
+ }
+ if (sealToSubmit != null) {
+ submitSeal(sealToSubmit);
+ }
return true;
}
+ private void submitSeal(PendingSeal seal) {
+ try {
+ snapshotBuildExecutor.execute(() -> buildAndCommitSeal(seal));
+ } catch (RejectedExecutionException e) {
+ handleSealSubmissionFailure(seal, e);
+ } catch (RuntimeException e) {
+ handleSealSubmissionFailure(seal, e);
+ }
+ }
+
+ private void handleSealSubmissionFailure(PendingSeal seal, RuntimeException error) {
+ boolean handled = false;
+ synchronized (this) {
+ if (pendingSeal == seal) {
+ if (lifecycle == Lifecycle.OPEN && lifecycleGeneration == seal.generation()) {
+ seal.bucket().moveAllMessagesToSharedQueue(sharedBucketPriorityQueue);
+ rescheduleTimer(0);
+ }
+ pendingSeal = null;
+ pendingSealMemoryUsage.set(0);
+ pendingSealMessageCount.set(0);
+ handled = true;
+ }
+ }
+ if (handled) {
+ log.warn()
+ .attr("startLedgerId", seal.bucket().startLedgerId)
+ .attr("endLedgerId", seal.bucket().endLedgerId)
+ .exception(error)
+ .log("Snapshot build executor rejected delayed-delivery bucket seal; using memory mode");
+ stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.create);
+ finishSeal(seal);
+ }
+ }
+
+ private void buildAndCommitSeal(PendingSeal seal) {
+ MutableBucket.SnapshotBuildResult result;
+ try {
+ result = seal.bucket().buildSnapshot(timeStepPerBucketSnapshotSegmentInMillis,
+ maxIndexesPerBucketSnapshotSegment);
+ if (result == null) {
+ throw new IllegalStateException("Detached mutable bucket is empty");
+ }
+ } catch (MutableBucket.SnapshotBuildException e) {
+ handleSealBuildFailure(seal, e);
+ if (e.getCause() instanceof Error error) {
+ throw error;
+ }
+ return;
+ } catch (RuntimeException e) {
+ handleSealBuildFailure(seal, e);
+ return;
+ }
+
+ CompletableFuture persistencePromise = null;
+ Throwable commitError = null;
+ synchronized (this) {
+ if (pendingSeal == seal && lifecycle == Lifecycle.OPEN
+ && lifecycleGeneration == seal.generation()) {
+ try {
+ result.addFirstSegmentTo(sharedBucketPriorityQueue);
+ persistencePromise = new CompletableFuture<>();
+ result.bucket().setSnapshotCreateFuture(persistencePromise);
+ afterCreateImmutableBucket(Pair.of(result.bucket(), result.firstSegmentLastIndex()),
+ seal.startTime());
+ pendingSeal = null;
+ pendingSealMemoryUsage.set(0);
+ pendingSealMessageCount.set(0);
+ triggerTrimAndMergeIfNeeded();
+ rescheduleTimer(0);
+ } catch (RuntimeException e) {
+ commitError = e;
+ persistencePromise = null;
+ result.addAllSegmentsTo(sharedBucketPriorityQueue);
+ removeBucket(Range.closed(result.bucket().getStartLedgerId(),
+ result.bucket().getEndLedgerId()));
+ snapshotSegmentLastIndexMap.remove(new SnapshotKey(
+ result.firstSegmentLastIndex().getLedgerId(),
+ result.firstSegmentLastIndex().getEntryId()));
+ pendingSeal = null;
+ pendingSealMemoryUsage.set(0);
+ pendingSealMessageCount.set(0);
+ rescheduleTimer(0);
+ }
+ } else if (pendingSeal == seal) {
+ pendingSeal = null;
+ pendingSealMemoryUsage.set(0);
+ pendingSealMessageCount.set(0);
+ }
+ }
+
+ if (persistencePromise != null) {
+ startSnapshotPersistence(result, persistencePromise);
+ } else if (commitError != null) {
+ log.error()
+ .attr("startLedgerId", seal.bucket().startLedgerId)
+ .attr("endLedgerId", seal.bucket().endLedgerId)
+ .exception(commitError)
+ .log("Failed to commit delayed-delivery bucket snapshot build; using memory mode");
+ stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.create);
+ }
+ finishSeal(seal);
+ }
+
+ private void startSnapshotPersistence(MutableBucket.SnapshotBuildResult result,
+ CompletableFuture persistencePromise) {
+ final CompletableFuture persistenceFuture;
+ try {
+ persistenceFuture = result.bucket().asyncSaveBucketSnapshot(
+ result.snapshotMetadata(), result.snapshotSegments());
+ if (persistenceFuture == null) {
+ throw new NullPointerException("Snapshot storage returned a null future");
+ }
+ } catch (Throwable t) {
+ persistencePromise.completeExceptionally(t);
+ rescheduleAfterAsyncOperation();
+ return;
+ }
+ persistenceFuture.whenComplete((bucketId, error) -> {
+ if (error == null) {
+ persistencePromise.complete(bucketId);
+ } else {
+ persistencePromise.completeExceptionally(error);
+ }
+ rescheduleAfterAsyncOperation();
+ });
+ }
+
+ private synchronized void rescheduleAfterAsyncOperation() {
+ if (lifecycle == Lifecycle.OPEN) {
+ rescheduleTimer(0);
+ }
+ }
+
+ private void handleSealBuildFailure(PendingSeal seal, RuntimeException error) {
+ boolean handled = false;
+ synchronized (this) {
+ if (pendingSeal == seal) {
+ if (lifecycle == Lifecycle.OPEN && lifecycleGeneration == seal.generation()) {
+ if (error instanceof MutableBucket.SnapshotBuildException buildException) {
+ buildException.restoreTo(sharedBucketPriorityQueue);
+ } else {
+ seal.bucket().moveAllMessagesToSharedQueue(sharedBucketPriorityQueue);
+ }
+ rescheduleTimer(0);
+ }
+ pendingSeal = null;
+ pendingSealMemoryUsage.set(0);
+ pendingSealMessageCount.set(0);
+ handled = true;
+ }
+ }
+ if (handled) {
+ log.error()
+ .attr("startLedgerId", seal.bucket().startLedgerId)
+ .attr("endLedgerId", seal.bucket().endLedgerId)
+ .exception(error)
+ .log("Failed to build delayed-delivery bucket snapshot; using memory mode");
+ stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.create);
+ finishSeal(seal);
+ }
+ }
+
+ private void finishSeal(PendingSeal seal) {
+ try {
+ seal.bucket().close();
+ } catch (RuntimeException error) {
+ log.warn().exception(error).log("Failed to release detached delayed-delivery bucket");
+ } finally {
+ seal.completion().complete(null);
+ }
+ }
+
+ private void triggerTrimAndMergeIfNeeded() {
+ if (maxNumBuckets > 0 && bucketsCount.get() > maxNumBuckets
+ && (trimFuture == null || trimFuture.isDone())) {
+ trimFuture = asyncTrimImmutableBuckets()
+ .thenCompose(ignore -> asyncMergeBucketSnapshot())
+ .whenComplete((ignore, t) -> {
+ if (t != null) {
+ log.warn().exception(t).log("Failed to trim or merge bucket snapshots");
+ }
+ });
+ }
+ }
+
@VisibleForTesting
synchronized List selectMergedBuckets(final List values, int mergeNum) {
if (values.size() < 2 || mergeNum < 2) {
@@ -622,6 +884,9 @@ private synchronized CompletableFuture asyncMergeBucketSnapshot(List 0 && nextDeliveryTime() <= cutoffTime;
@@ -633,19 +898,16 @@ public synchronized boolean hasMessageAvailable() {
@Override
protected synchronized long nextDeliveryTime() {
- if (lastMutableBucket.isEmpty() && !sharedBucketPriorityQueue.isEmpty()) {
- return sharedBucketPriorityQueue.peekN1();
- } else if (sharedBucketPriorityQueue.isEmpty() && !lastMutableBucket.isEmpty()) {
- return lastMutableBucket.nextDeliveryTime();
- } else if (lastMutableBucket.isEmpty() && sharedBucketPriorityQueue.isEmpty()) {
- // numberDelayedMessages can be > 0 while both queues are empty (e.g. remaining
- // messages live in not-yet-loaded snapshot segments). Returning Long.MAX_VALUE
- // signals "no imminent delivery" without throwing on the empty queues.
- return Long.MAX_VALUE;
+ long timestamp = lastMutableBucket.isEmpty() ? Long.MAX_VALUE : lastMutableBucket.nextDeliveryTime();
+ if (!sharedBucketPriorityQueue.isEmpty()) {
+ timestamp = Math.min(timestamp, sharedBucketPriorityQueue.peekN1());
+ }
+ if (pendingSeal != null) {
+ timestamp = Math.min(timestamp, pendingSeal.nextDeliveryTime());
}
- long timestamp = lastMutableBucket.nextDeliveryTime();
- long bucketTimestamp = sharedBucketPriorityQueue.peekN1();
- return Math.min(timestamp, bucketTimestamp);
+ // numberDelayedMessages can be > 0 while all in-memory queues are empty because later
+ // messages can live in not-yet-loaded snapshot segments.
+ return timestamp;
}
@Override
@@ -655,11 +917,15 @@ public long getNumberOfDelayedMessages() {
@Override
public long getBufferMemoryUsage() {
- return this.lastMutableBucket.getBufferMemoryUsage() + sharedBucketPriorityQueue.bytesCapacity();
+ return this.lastMutableBucket.getBufferMemoryUsage() + sharedBucketPriorityQueue.bytesCapacity()
+ + pendingSealMemoryUsage.get();
}
@Override
public synchronized NavigableSet getScheduledMessages(int maxMessages) {
+ if (lifecycle != Lifecycle.OPEN) {
+ return Collections.emptyNavigableSet();
+ }
if (!checkPendingLoadDone()) {
log.debug("Skip getScheduledMessages to wait for bucket snapshot load finish");
return Collections.emptyNavigableSet();
@@ -756,7 +1022,7 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages)
stats.recordSuccessEvent(BucketDelayedMessageIndexStats.Type.load,
System.currentTimeMillis() - loadStartTime);
}
- rescheduleTimer(0);
+ rescheduleAfterAsyncOperation();
});
if (!checkPendingLoadDone() || loadFuture.isCompletedExceptionally()) {
@@ -792,27 +1058,69 @@ public boolean shouldPauseAllDeliveries() {
}
@Override
- public synchronized CompletableFuture clear() {
- // Wait for any in-flight trim+merge to settle, then clear.
- // Reuse trimFuture to block new triggers until the clear chain completes.
- CompletableFuture before = trimFuture != null && !trimFuture.isDone()
- ? trimFuture : CompletableFuture.completedFuture(null);
- trimFuture = before
- .exceptionally(t -> {
- log.warn().exception(t).log("Trim/merge buckets failed, but still clear");
- return null;
- })
- .thenCompose(__ -> {
- synchronized (BucketDelayedDeliveryTracker.this) {
- CompletableFuture future = cleanImmutableBuckets();
- sharedBucketPriorityQueue.clear();
- index.clear();
- lastMutableBucket.clear();
- snapshotSegmentLastIndexMap.clear();
- return future;
- }
- });
- return trimFuture;
+ public CompletableFuture clear() {
+ final CompletableFuture result;
+ final CompletableFuture before;
+ final long clearGeneration;
+ synchronized (this) {
+ if (lifecycle == Lifecycle.CLOSING || lifecycle == Lifecycle.CLOSED) {
+ return closeFuture != null ? closeFuture : CompletableFuture.completedFuture(null);
+ }
+ if (lifecycle == Lifecycle.CLEARING) {
+ return clearFuture;
+ }
+
+ lifecycle = Lifecycle.CLEARING;
+ clearGeneration = ++lifecycleGeneration;
+ // Reset the current timeout before temporarily rejecting timer callbacks. New messages
+ // can arm the tracker again after this clear operation reopens it.
+ super.close();
+ result = new CompletableFuture<>();
+ clearFuture = result;
+
+ List> operations = new ArrayList<>();
+ if (pendingSeal != null) {
+ operations.add(pendingSeal.completion());
+ }
+ if (pendingLoad != null) {
+ operations.add(pendingLoad);
+ }
+ if (trimFuture != null && !trimFuture.isDone()) {
+ operations.add(trimFuture);
+ }
+ before = FutureUtil.waitForAll(operations).exceptionally(t -> {
+ log.warn().exception(t).log("An asynchronous bucket operation failed, but still clear");
+ return null;
+ });
+ // Block new trim/merge triggers until this clear operation has settled.
+ trimFuture = result;
+ }
+
+ before.thenCompose(__ -> {
+ synchronized (BucketDelayedDeliveryTracker.this) {
+ if (lifecycle != Lifecycle.CLEARING || lifecycleGeneration != clearGeneration) {
+ return CompletableFuture.completedFuture(null);
+ }
+ CompletableFuture deleteFuture = cleanImmutableBuckets();
+ sharedBucketPriorityQueue.clear();
+ index.clear();
+ lastMutableBucket.clear();
+ snapshotSegmentLastIndexMap.clear();
+ return deleteFuture;
+ }
+ }).whenComplete((__, error) -> {
+ synchronized (BucketDelayedDeliveryTracker.this) {
+ if (lifecycle == Lifecycle.CLEARING && lifecycleGeneration == clearGeneration) {
+ lifecycle = Lifecycle.OPEN;
+ }
+ }
+ if (error == null) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(error);
+ }
+ });
+ return result;
}
@Override
@@ -823,19 +1131,78 @@ public void close() {
@Override
public CompletableFuture closeAsync() {
- List> completableFutures;
+ final CompletableFuture result;
+ final CompletableFuture before;
synchronized (this) {
+ if (closeFuture != null) {
+ return closeFuture;
+ }
+
+ lifecycle = Lifecycle.CLOSING;
+ lifecycleGeneration++;
super.close();
- lastMutableBucket.close();
- sharedBucketPriorityQueue.close();
- completableFutures = immutableBuckets.asMapOfRanges().values().stream()
- .map(bucket -> bucket.getSnapshotCreateFuture().orElse(NULL_LONG_PROMISE)).toList();
- }
- return FutureUtil.waitForAll(completableFutures)
- .exceptionally(e -> {
- log.warn().exception(e).log("Failed wait to snapshot generate");
- return null;
- });
+ result = new CompletableFuture<>();
+ closeFuture = result;
+
+ List> operations = new ArrayList<>();
+ if (pendingSeal != null) {
+ operations.add(pendingSeal.completion());
+ }
+ if (pendingLoad != null) {
+ operations.add(pendingLoad);
+ }
+ if (trimFuture != null && !trimFuture.isDone()) {
+ operations.add(trimFuture);
+ }
+ if (clearFuture != null && !clearFuture.isDone() && clearFuture != trimFuture) {
+ operations.add(clearFuture);
+ }
+ before = FutureUtil.waitForAll(operations).exceptionally(t -> {
+ log.warn().exception(t).log("Failed waiting for delayed-delivery bucket operations during close");
+ return null;
+ });
+ }
+
+ before.thenCompose(__ -> {
+ List> snapshotFutures;
+ synchronized (BucketDelayedDeliveryTracker.this) {
+ snapshotFutures = immutableBuckets.asMapOfRanges().values().stream()
+ .map(bucket -> bucket.getSnapshotCreateFuture().orElse(NULL_LONG_PROMISE)).toList();
+ }
+ return FutureUtil.waitForAll(snapshotFutures).exceptionally(error -> {
+ log.warn().exception(error).log("Failed waiting for bucket snapshot creation during close");
+ return null;
+ });
+ }).whenComplete((__, error) -> {
+ Throwable completionError = error;
+ synchronized (BucketDelayedDeliveryTracker.this) {
+ try {
+ lastMutableBucket.close();
+ } catch (Throwable closeError) {
+ if (completionError == null) {
+ completionError = closeError;
+ } else {
+ completionError.addSuppressed(closeError);
+ }
+ }
+ try {
+ sharedBucketPriorityQueue.close();
+ } catch (Throwable closeError) {
+ if (completionError == null) {
+ completionError = closeError;
+ } else {
+ completionError.addSuppressed(closeError);
+ }
+ }
+ lifecycle = Lifecycle.CLOSED;
+ }
+ if (completionError == null) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(completionError);
+ }
+ });
+ return result;
}
private CompletableFuture cleanImmutableBuckets() {
@@ -861,7 +1228,8 @@ public synchronized boolean containsMessage(long ledgerId, long entryId) {
public Map genTopicMetricMap() {
stats.recordNumOfBuckets((int) (bucketsCount.get() + 1));
- stats.recordDelayedMessageIndexLoaded(this.sharedBucketPriorityQueue.size() + this.lastMutableBucket.size());
+ stats.recordDelayedMessageIndexLoaded(this.sharedBucketPriorityQueue.size() + this.lastMutableBucket.size()
+ + pendingSealMessageCount.get());
stats.recordBucketSnapshotSizeBytes(totalSnapshotLengthBytes.get());
return stats.genTopicMetricMap();
}
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 925c4756019c7..4f27af5bdf6da 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,6 @@
import static org.apache.bookkeeper.mledger.util.Futures.executeWithRetry;
import static org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.DELAYED_BUCKET_KEY_PREFIX;
-import static org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.NULL_LONG_PROMISE;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
@@ -349,8 +348,22 @@ CompletableFuture asyncDeleteBucketSnapshot(BucketDelayedMessageIndexStats
CompletableFuture clear(BucketDelayedMessageIndexStats stats) {
delayedIndexBitMap.clear();
- return getSnapshotCreateFuture().orElse(NULL_LONG_PROMISE).exceptionally(e -> null)
- .thenCompose(__ -> asyncDeleteBucketSnapshot(stats));
+ Optional> createFuture = getSnapshotCreateFuture();
+ if (createFuture.isEmpty()) {
+ // Recovered buckets don't have a create future; their id is read from the cursor property.
+ return asyncDeleteBucketSnapshot(stats);
+ }
+ return createFuture.get()
+ .handle((createdBucketId, error) -> getBucketId().orElse(error == null ? createdBucketId : null))
+ .thenCompose(createdBucketId -> {
+ if (createdBucketId == null || createdBucketId < 0) {
+ return CompletableFuture.completedFuture(null);
+ }
+ if (getBucketId().isEmpty()) {
+ setBucketId(createdBucketId);
+ }
+ return asyncDeleteBucketSnapshot(stats);
+ });
}
protected CompletableFuture asyncUpdateSnapshotLength() {
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 0d3630987af8c..7fa30370f6066 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
@@ -50,27 +50,41 @@ class MutableBucket implements AutoCloseable {
this.priorityQueue = new TripleLongPriorityQueue();
}
- Pair sealBucketAndAsyncPersistent(
- long timeStepPerBucketSnapshotSegment,
- int maxIndexesPerBucketSnapshotSegment,
- TripleLongPriorityQueue sharedQueue) {
- return createImmutableBucketAndAsyncPersistent(timeStepPerBucketSnapshotSegment,
- maxIndexesPerBucketSnapshotSegment, sharedQueue,
- TripleLongPriorityDelayedIndexQueue.wrap(priorityQueue), startLedgerId, endLedgerId);
- }
-
Pair createImmutableBucketAndAsyncPersistent(
final long timeStepPerBucketSnapshotSegment, final int maxIndexesPerBucketSnapshotSegment,
TripleLongPriorityQueue sharedQueue, DelayedIndexQueue delayedIndexQueue, final long startLedgerId,
final long endLedgerId) {
- log.debug()
- .attr("dispatcher", ctx.dispatcherName())
- .attr("startLedgerId", startLedgerId)
- .attr("endLedgerId", endLedgerId)
- .log("Creating bucket snapshot");
- if (delayedIndexQueue.isEmpty()) {
+ SnapshotBuildResult result = buildSnapshot(timeStepPerBucketSnapshotSegment,
+ maxIndexesPerBucketSnapshotSegment, delayedIndexQueue, startLedgerId, endLedgerId);
+ if (result == null) {
return null;
}
+
+ result.addFirstSegmentTo(sharedQueue);
+ CompletableFuture future = result.bucket().asyncSaveBucketSnapshot(
+ result.snapshotMetadata(), result.snapshotSegments());
+ result.bucket().setSnapshotCreateFuture(future);
+ return Pair.of(result.bucket(), result.firstSegmentLastIndex());
+ }
+
+ SnapshotBuildResult buildSnapshot(long timeStepPerBucketSnapshotSegment,
+ int maxIndexesPerBucketSnapshotSegment) {
+ return buildSnapshot(timeStepPerBucketSnapshotSegment, maxIndexesPerBucketSnapshotSegment,
+ TripleLongPriorityDelayedIndexQueue.wrap(priorityQueue), startLedgerId, endLedgerId);
+ }
+
+ private SnapshotBuildResult buildSnapshot(
+ final long timeStepPerBucketSnapshotSegment, final int maxIndexesPerBucketSnapshotSegment,
+ DelayedIndexQueue delayedIndexQueue, final long startLedgerId, final long endLedgerId) {
+ log.debug()
+ .attr("dispatcher", ctx.dispatcherName())
+ .attr("startLedgerId", startLedgerId)
+ .attr("endLedgerId", endLedgerId)
+ .log("Building bucket snapshot");
+ if (delayedIndexQueue.isEmpty()) {
+ return null;
+ }
+
long numMessages = 0;
List bucketSnapshotSegments = new ArrayList<>();
@@ -84,91 +98,86 @@ Pair createImmutableBucketAndAsyncPersistent(
List firstScheduleTimestamps = new ArrayList<>();
long currentTimestampUpperLimit = 0;
long currentFirstTimestamp = 0L;
- while (!delayedIndexQueue.isEmpty()) {
- final long timestamp = delayedIndexQueue.peekTimestamp();
- if (currentTimestampUpperLimit == 0) {
- currentFirstTimestamp = timestamp;
- firstScheduleTimestamps.add(currentFirstTimestamp);
- currentTimestampUpperLimit = timestamp + timeStepPerBucketSnapshotSegment - 1;
- }
-
- DelayedIndex delayedIndex = snapshotSegment.addIndexe();
- delayedIndexQueue.popToObject(delayedIndex);
-
- final long ledgerId = delayedIndex.getLedgerId();
- final long entryId = delayedIndex.getEntryId();
-
- checkArgument(ledgerId >= startLedgerId && ledgerId <= endLedgerId);
-
- // Move first segment of bucket snapshot to sharedBucketPriorityQueue
- if (segmentMetadataList.size() == 0) {
- sharedQueue.add(timestamp, ledgerId, entryId);
- }
-
- bitMap.computeIfAbsent(ledgerId, k -> LongBitmaps.create()).add(entryId);
-
- numMessages++;
-
- if (delayedIndexQueue.isEmpty() || delayedIndexQueue.peekTimestamp() > currentTimestampUpperLimit
- || (maxIndexesPerBucketSnapshotSegment != -1
- && snapshotSegment.getIndexesCount() >= maxIndexesPerBucketSnapshotSegment)) {
- segmentMetadata.setMaxScheduleTimestamp(timestamp);
- segmentMetadata.setMinScheduleTimestamp(currentFirstTimestamp);
- currentTimestampUpperLimit = 0;
-
- Iterator> iterator = bitMap.entrySet().iterator();
- while (iterator.hasNext()) {
- final var entry = iterator.next();
- final var lId = entry.getKey();
- final var bm = entry.getValue();
- segmentMetadata.putDelayedIndexBitMap(lId, bm.serialize());
- immutableBucketBitMap.compute(lId, (__, bm0) -> {
- if (bm0 == null) {
- return bm;
- }
- bm0.or(bm);
- return bm0;
- });
- iterator.remove();
+ try {
+ while (!delayedIndexQueue.isEmpty()) {
+ final long timestamp = delayedIndexQueue.peekTimestamp();
+ if (currentTimestampUpperLimit == 0) {
+ currentFirstTimestamp = timestamp;
+ firstScheduleTimestamps.add(currentFirstTimestamp);
+ currentTimestampUpperLimit = timestamp + timeStepPerBucketSnapshotSegment - 1;
}
- segmentMetadataList.add(segmentMetadata);
- segmentMetadata = new SnapshotSegmentMetadata();
-
- bucketSnapshotSegments.add(snapshotSegment);
- snapshotSegment = new SnapshotSegment();
+ DelayedIndex delayedIndex = snapshotSegment.addIndexe();
+ delayedIndexQueue.popToObject(delayedIndex);
+
+ final long ledgerId = delayedIndex.getLedgerId();
+ final long entryId = delayedIndex.getEntryId();
+
+ checkArgument(ledgerId >= startLedgerId && ledgerId <= endLedgerId);
+
+ bitMap.computeIfAbsent(ledgerId, k -> LongBitmaps.create()).add(entryId);
+
+ numMessages++;
+
+ if (delayedIndexQueue.isEmpty() || delayedIndexQueue.peekTimestamp() > currentTimestampUpperLimit
+ || (maxIndexesPerBucketSnapshotSegment != -1
+ && snapshotSegment.getIndexesCount() >= maxIndexesPerBucketSnapshotSegment)) {
+ segmentMetadata.setMaxScheduleTimestamp(timestamp);
+ segmentMetadata.setMinScheduleTimestamp(currentFirstTimestamp);
+ currentTimestampUpperLimit = 0;
+
+ Iterator> iterator = bitMap.entrySet().iterator();
+ while (iterator.hasNext()) {
+ final var entry = iterator.next();
+ final var lId = entry.getKey();
+ final var bm = entry.getValue();
+ segmentMetadata.putDelayedIndexBitMap(lId, bm.serialize());
+ immutableBucketBitMap.compute(lId, (__, bm0) -> {
+ if (bm0 == null) {
+ return bm;
+ }
+ bm0.or(bm);
+ return bm0;
+ });
+ iterator.remove();
+ }
+
+ segmentMetadataList.add(segmentMetadata);
+ segmentMetadata = new SnapshotSegmentMetadata();
+
+ bucketSnapshotSegments.add(snapshotSegment);
+ snapshotSegment = new SnapshotSegment();
+ }
}
- }
- SnapshotMetadata bucketSnapshotMetadata = new SnapshotMetadata();
- for (SnapshotSegmentMetadata sm : segmentMetadataList) {
- bucketSnapshotMetadata.addMetadata().copyFrom(sm);
- }
-
- final int lastSegmentEntryId = segmentMetadataList.size();
+ SnapshotMetadata bucketSnapshotMetadata = new SnapshotMetadata();
+ for (SnapshotSegmentMetadata sm : segmentMetadataList) {
+ bucketSnapshotMetadata.addMetadata().copyFrom(sm);
+ }
- ImmutableBucket bucket = new ImmutableBucket(ctx, startLedgerId, endLedgerId);
- bucket.setCurrentSegmentEntryId(1);
- bucket.setNumberBucketDelayedMessages(numMessages);
- bucket.setLastSegmentEntryId(lastSegmentEntryId);
- bucket.setFirstScheduleTimestamps(firstScheduleTimestamps);
- bucket.setDelayedIndexBitMap(immutableBucketBitMap);
+ final int lastSegmentEntryId = segmentMetadataList.size();
- // Skip first segment, because it has already been loaded
- List snapshotSegments = bucketSnapshotSegments.subList(1, bucketSnapshotSegments.size());
- bucket.setSnapshotSegments(snapshotSegments);
+ ImmutableBucket bucket = new ImmutableBucket(ctx, startLedgerId, endLedgerId);
+ bucket.setCurrentSegmentEntryId(1);
+ bucket.setNumberBucketDelayedMessages(numMessages);
+ bucket.setLastSegmentEntryId(lastSegmentEntryId);
+ bucket.setFirstScheduleTimestamps(firstScheduleTimestamps);
+ bucket.setDelayedIndexBitMap(immutableBucketBitMap);
- // Add the first snapshot segment last message to snapshotSegmentLastMessageTable
- checkArgument(!bucketSnapshotSegments.isEmpty());
- SnapshotSegment firstSnapshotSegment = bucketSnapshotSegments.get(0);
- DelayedIndex lastDelayedIndex = firstSnapshotSegment.getIndexeAt(firstSnapshotSegment.getIndexesCount() - 1);
- Pair result = Pair.of(bucket, lastDelayedIndex);
+ // Skip the first segment because the tracker loads it into the shared queue before committing the bucket.
+ bucket.setSnapshotSegments(new ArrayList<>(
+ bucketSnapshotSegments.subList(1, bucketSnapshotSegments.size())));
- CompletableFuture future = bucket.asyncSaveBucketSnapshot(
- bucketSnapshotMetadata, bucketSnapshotSegments);
- bucket.setSnapshotCreateFuture(future);
+ checkArgument(!bucketSnapshotSegments.isEmpty());
+ SnapshotSegment firstSnapshotSegment = bucketSnapshotSegments.get(0);
+ DelayedIndex lastDelayedIndex =
+ firstSnapshotSegment.getIndexeAt(firstSnapshotSegment.getIndexesCount() - 1);
- return result;
+ return new SnapshotBuildResult(bucket, bucketSnapshotMetadata,
+ bucketSnapshotSegments, lastDelayedIndex);
+ } catch (Throwable t) {
+ throw new SnapshotBuildException(t, bucketSnapshotSegments, snapshotSegment, delayedIndexQueue);
+ }
}
void moveScheduledMessageToSharedQueue(long cutoffTime, TripleLongPriorityQueue sharedBucketPriorityQueue) {
@@ -186,6 +195,13 @@ void moveScheduledMessageToSharedQueue(long cutoffTime, TripleLongPriorityQueue
}
}
+ void moveAllMessagesToSharedQueue(TripleLongPriorityQueue sharedBucketPriorityQueue) {
+ while (!priorityQueue.isEmpty()) {
+ sharedBucketPriorityQueue.add(priorityQueue.peekN1(), priorityQueue.peekN2(), priorityQueue.peekN3());
+ priorityQueue.pop();
+ }
+ }
+
void resetLastMutableBucketRange() {
this.startLedgerId = -1L;
this.endLedgerId = -1L;
@@ -223,4 +239,58 @@ void addMessage(long ledgerId, long entryId, long deliverAt) {
}
this.endLedgerId = ledgerId;
}
+
+ static record SnapshotBuildResult(ImmutableBucket bucket,
+ SnapshotMetadata snapshotMetadata,
+ List snapshotSegments,
+ DelayedIndex firstSegmentLastIndex) {
+
+ void addFirstSegmentTo(TripleLongPriorityQueue sharedQueue) {
+ for (DelayedIndex delayedIndex : snapshotSegments.get(0).getIndexesList()) {
+ sharedQueue.add(delayedIndex.getTimestamp(), delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ }
+ }
+
+ void addAllSegmentsTo(TripleLongPriorityQueue sharedQueue) {
+ for (SnapshotSegment segment : snapshotSegments) {
+ for (DelayedIndex delayedIndex : segment.getIndexesList()) {
+ sharedQueue.add(delayedIndex.getTimestamp(), delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ }
+ }
+ }
+ }
+
+ static final class SnapshotBuildException extends RuntimeException {
+ private final List completedSegments;
+ private final SnapshotSegment currentSegment;
+ private final DelayedIndexQueue remainingIndexes;
+
+ SnapshotBuildException(Throwable cause, List completedSegments,
+ SnapshotSegment currentSegment, DelayedIndexQueue remainingIndexes) {
+ super(cause);
+ this.completedSegments = completedSegments;
+ this.currentSegment = currentSegment;
+ this.remainingIndexes = remainingIndexes;
+ }
+
+ void restoreTo(TripleLongPriorityQueue sharedQueue) {
+ for (SnapshotSegment segment : completedSegments) {
+ restoreSegment(segment, sharedQueue);
+ }
+ if (completedSegments.isEmpty()
+ || completedSegments.get(completedSegments.size() - 1) != currentSegment) {
+ restoreSegment(currentSegment, sharedQueue);
+ }
+ while (!remainingIndexes.isEmpty()) {
+ DelayedIndex delayedIndex = remainingIndexes.pop();
+ sharedQueue.add(delayedIndex.getTimestamp(), delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ }
+ }
+
+ private static void restoreSegment(SnapshotSegment segment, TripleLongPriorityQueue sharedQueue) {
+ for (DelayedIndex delayedIndex : segment.getIndexesList()) {
+ sharedQueue.add(delayedIndex.getTimestamp(), delayedIndex.getLedgerId(), delayedIndex.getEntryId());
+ }
+ }
+ }
}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java
index 54140cb2bfdf7..396b70e166d08 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java
@@ -22,7 +22,9 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotSame;
@@ -35,6 +37,7 @@
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.time.Clock;
+import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -46,9 +49,12 @@
import java.util.TreeSet;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
import lombok.Cleanup;
import org.apache.bookkeeper.mledger.ManagedCursor;
import org.apache.bookkeeper.mledger.ManagedLedger;
@@ -1286,6 +1292,264 @@ public void testLateSnapshotLengthUpdateAfterTrimDoesNotInflateCounter() throws
}
}
+ @Test
+ public void testSnapshotBuildIsSubmittedOutsideTrackerMonitor() throws Exception {
+ AtomicReference buildTask = new AtomicReference<>();
+ AtomicReference trackerReference = new AtomicReference<>();
+ Executor executor = command -> {
+ Assert.assertFalse(Thread.holdsLock(trackerReference.get()),
+ "Snapshot build was submitted while holding the tracker monitor");
+ Assert.assertTrue(buildTask.compareAndSet(null, command));
+ };
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(executor, now, storage);
+ trackerReference.set(tracker);
+ when(storage.createBucketSnapshot(any(), any(), any(), any(), any())).thenAnswer(__ -> {
+ Assert.assertFalse(Thread.holdsLock(tracker),
+ "Snapshot serialization was invoked while holding the tracker monitor");
+ return CompletableFuture.completedFuture(1L);
+ });
+
+ addMessagesAndTriggerSeal(tracker);
+
+ assertTrue(buildTask.get() != null, "The detached bucket should have been submitted for background build");
+ assertEquals(tracker.getLastMutableBucket().size(), 1);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 4);
+ assertTrue(tracker.getImmutableBuckets().asMapOfRanges().isEmpty());
+
+ buildTask.get().run();
+
+ assertEquals(tracker.getImmutableBuckets().asMapOfRanges().size(), 1);
+ tracker.close();
+ }
+
+ @Test
+ public void testRejectedSealSubmissionFallsBackToMemoryMode() throws Exception {
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ Executor rejectingExecutor = command -> {
+ throw new RejectedExecutionException("test rejection");
+ };
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(rejectingExecutor, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+
+ assertTrue(tracker.getImmutableBuckets().asMapOfRanges().isEmpty());
+ assertEquals(tracker.getSharedBucketPriorityQueue().size(), 3);
+ assertEquals(tracker.getLastMutableBucket().size(), 1);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 4);
+
+ now.set(2_000);
+ assertEquals(tracker.getScheduledMessages(10).size(), 4);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ tracker.close();
+ }
+
+ @Test
+ public void testSnapshotBuildFailureRestoresAllIndexes() throws Exception {
+ AtomicReference buildTask = new AtomicReference<>();
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(buildTask::set, now, storage);
+
+ for (int entryId = 0; entryId < 3; entryId++) {
+ assertTrue(tracker.addMessage(1, entryId, 1_000));
+ }
+ // Corrupt the test bucket range so the builder fails after destructively popping an index.
+ tracker.getLastMutableBucket().endLedgerId = 0;
+ assertTrue(tracker.addMessage(2, 0, 1_000));
+ buildTask.get().run();
+
+ assertTrue(tracker.getImmutableBuckets().asMapOfRanges().isEmpty());
+ assertEquals(tracker.getSharedBucketPriorityQueue().size(), 3);
+ assertEquals(tracker.getLastMutableBucket().size(), 1);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 4);
+
+ now.set(2_000);
+ assertEquals(tracker.getScheduledMessages(10).size(), 4);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ tracker.close();
+ }
+
+ @Test
+ public void testClearDuringSealDoesNotResurrectBucket() throws Exception {
+ AtomicReference buildTask = new AtomicReference<>();
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(buildTask::set, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ CompletableFuture clearFuture = tracker.clear();
+ assertFalse("Clear should wait for the detached bucket build to observe its generation fence",
+ clearFuture.isDone());
+
+ buildTask.get().run();
+ clearFuture.get(5, TimeUnit.SECONDS);
+
+ assertTrue(tracker.getImmutableBuckets().asMapOfRanges().isEmpty());
+ assertEquals(tracker.getSharedBucketPriorityQueue().size(), 0);
+ assertEquals(tracker.getLastMutableBucket().size(), 0);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ verify(storage, never()).createBucketSnapshot(any(), any(), any(), any(), any());
+
+ assertTrue(tracker.addMessage(3, 0, 3_000), "Tracker should be reusable after clear");
+ tracker.close();
+ }
+
+ @Test
+ public void testPersistenceFailureDuringClearDoesNotRestoreMessages() throws Exception {
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ CompletableFuture persistenceFuture = new CompletableFuture<>();
+ when(storage.createBucketSnapshot(any(), any(), any(), any(), any())).thenReturn(persistenceFuture);
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(Runnable::run, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ assertEquals(tracker.getImmutableBuckets().asMapOfRanges().size(), 1);
+
+ CompletableFuture clearFuture = tracker.clear();
+ assertFalse("Clear should wait for the in-flight snapshot persistence", clearFuture.isDone());
+ persistenceFuture.completeExceptionally(new RuntimeException("test persistence failure"));
+ clearFuture.get(5, TimeUnit.SECONDS);
+
+ assertTrue(tracker.getImmutableBuckets().asMapOfRanges().isEmpty());
+ assertEquals(tracker.getSharedBucketPriorityQueue().size(), 0);
+ assertEquals(tracker.getLastMutableBucket().size(), 0);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ verify(storage, never()).deleteBucketSnapshot(anyLong());
+ tracker.close();
+ }
+
+ @Test
+ public void testClearDeletesSnapshotWhenCreationChainFailsAfterAllocatingId() throws Exception {
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ CompletableFuture persistenceFuture = new CompletableFuture<>();
+ when(storage.createBucketSnapshot(any(), any(), any(), any(), any())).thenReturn(persistenceFuture);
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(Runnable::run, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ ImmutableBucket bucket = tracker.getImmutableBuckets().asMapOfRanges().values().iterator().next();
+ bucket.setBucketId(42L);
+
+ CompletableFuture clearFuture = tracker.clear();
+ persistenceFuture.completeExceptionally(new RuntimeException("test creation-chain failure"));
+ clearFuture.get(5, TimeUnit.SECONDS);
+
+ verify(storage).deleteBucketSnapshot(42L);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ tracker.close();
+ }
+
+ @Test
+ public void testCloseAsyncDuringSealDoesNotBlockCaller() throws Exception {
+ AtomicReference buildTask = new AtomicReference<>();
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(buildTask::set, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ CompletableFuture closeFuture = tracker.closeAsync();
+ assertFalse("closeAsync should return without waiting on the caller thread", closeFuture.isDone());
+
+ buildTask.get().run();
+ closeFuture.get(5, TimeUnit.SECONDS);
+
+ verify(storage, never()).createBucketSnapshot(any(), any(), any(), any(), any());
+ assertFalse("Closed tracker must reject new delayed messages", tracker.addMessage(3, 0, 3_000));
+ Assert.assertSame(tracker.closeAsync(), closeFuture);
+ }
+
+ @Test
+ public void testDueMessagesDuringSealRemainReachableAndDeduplicated() throws Exception {
+ AtomicReference buildTask = new AtomicReference<>();
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(buildTask::set, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ assertTrue(tracker.addMessage(1, 3, 1_000));
+ assertTrue(tracker.addMessage(1, 3, 1_000));
+ assertEquals(tracker.getNumberOfDelayedMessages(), 5);
+
+ now.set(2_000);
+ NavigableSet beforeBuild = tracker.getScheduledMessages(10);
+ assertEquals(beforeBuild.size(), 2);
+
+ buildTask.get().run();
+ NavigableSet afterBuild = tracker.getScheduledMessages(10);
+ assertEquals(afterBuild.size(), 3);
+ TreeSet delivered = new TreeSet<>(beforeBuild);
+ delivered.addAll(afterBuild);
+ assertEquals(delivered.size(), 5);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ tracker.close();
+ }
+
+ @Test
+ public void testLateMessageInSealingRangeSurvivesNextSealAndRemainsDeduplicated() throws Exception {
+ ArrayDeque buildTasks = new ArrayDeque<>();
+ AtomicLong now = new AtomicLong();
+ BucketSnapshotStorage storage = newCompletedSnapshotStorage();
+ BucketDelayedDeliveryTracker tracker = newAsyncSealTracker(buildTasks::addLast, now, storage);
+
+ addMessagesAndTriggerSeal(tracker);
+ assertTrue(tracker.addMessage(1, 3, 1_000));
+ assertTrue(tracker.addMessage(1, 3, 1_000));
+ assertTrue(tracker.addMessage(2, 1, 1_000));
+ assertTrue(tracker.addMessage(2, 2, 1_000));
+ assertEquals(tracker.getNumberOfDelayedMessages(), 7);
+
+ buildTasks.removeFirst().run();
+ assertTrue(tracker.addMessage(3, 0, 1_000));
+ assertEquals(buildTasks.size(), 1, "The second mutable bucket should seal independently");
+ buildTasks.removeFirst().run();
+
+ now.set(2_000);
+ NavigableSet delivered = tracker.getScheduledMessages(20);
+ assertEquals(delivered.size(), 8);
+ assertTrue(delivered.contains(PositionFactory.create(1, 3)));
+ assertEquals(tracker.getScheduledMessages(20).size(), 0);
+ assertEquals(tracker.getNumberOfDelayedMessages(), 0);
+ tracker.close();
+ }
+
+ private BucketDelayedDeliveryTracker newAsyncSealTracker(Executor executor, AtomicLong now,
+ BucketSnapshotStorage storage) throws Exception {
+ AbstractPersistentDispatcherMultipleConsumers testDispatcher =
+ mock(AbstractPersistentDispatcherMultipleConsumers.class);
+ ManagedCursor cursor = mock(ManagedCursor.class);
+ when(cursor.getName()).thenReturn("async-seal-cursor");
+ when(cursor.getCursorProperties()).thenReturn(Map.of());
+ when(cursor.putCursorProperty(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
+ when(cursor.removeCursorProperty(any())).thenReturn(CompletableFuture.completedFuture(null));
+ doReturn(cursor).when(testDispatcher).getCursor();
+ doReturn("persistent://public/default/async-seal / " + cursor.getName())
+ .when(testDispatcher).getName();
+ Clock testClock = mock(Clock.class);
+ when(testClock.millis()).thenAnswer(__ -> now.get());
+ bucketSnapshotStorage = storage;
+ return new BucketDelayedDeliveryTracker(testDispatcher, mock(Timer.class), 1, testClock,
+ true, storage, 3, TimeUnit.HOURS.toMillis(1), -1, 50, executor);
+ }
+
+ private static BucketSnapshotStorage newCompletedSnapshotStorage() {
+ BucketSnapshotStorage storage = mock(BucketSnapshotStorage.class);
+ when(storage.createBucketSnapshot(any(), any(), any(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(1L));
+ when(storage.getBucketSnapshotLength(anyLong())).thenReturn(CompletableFuture.completedFuture(0L));
+ when(storage.deleteBucketSnapshot(anyLong())).thenReturn(CompletableFuture.completedFuture(null));
+ return storage;
+ }
+
+ private static void addMessagesAndTriggerSeal(BucketDelayedDeliveryTracker tracker) {
+ for (int entryId = 0; entryId < 3; entryId++) {
+ assertTrue(tracker.addMessage(1, entryId, 1_000));
+ }
+ assertTrue(tracker.addMessage(2, 0, 1_000));
+ }
+
private static void assertCountersConsistent(BucketDelayedDeliveryTracker tracker) {
int liveBucketCount = tracker.getImmutableBuckets().asMapOfRanges().size();
long liveSnapshotLength = tracker.getImmutableBuckets().asMapOfRanges().values().stream()