This is an automated email from the ASF dual-hosted git repository.
lhotari pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/pulsar.git
The following commit(s) were added to refs/heads/branch-4.0 by this push:
new 112e527da25 [fix][broker] Fix replication stall when a cursor rewind
skips an in-flight read (#26106)
112e527da25 is described below
commit 112e527da25f1975cf97e18a63d91cc9ad4ec95d
Author: Lari Hotari <[email protected]>
AuthorDate: Wed Jul 1 02:47:12 2026 +0300
[fix][broker] Fix replication stall when a cursor rewind skips an in-flight
read (#26106)
(cherry picked from commit baf22113cd785545851f8fe6cfe758692859b867)
---
.../service/persistent/PersistentReplicator.java | 38 ++-
.../PersistentReplicatorInflightTaskTest.java | 330 +++++++++++++++++++++
2 files changed, 366 insertions(+), 2 deletions(-)
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java
index 222bcd277a5..10dd84b5e87 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java
@@ -354,13 +354,47 @@ public abstract class PersistentReplicator extends
AbstractReplicator
InFlightTask inFlightTask = (InFlightTask) ctx;
latestPublishTime = System.currentTimeMillis();
- // Release memory if terminated.
+ // The read result must be discarded because the replicator is
terminating or the cursor was
+ // rewound while this read was in flight (e.g. after a failed publish
or a schema change).
if (state == State.Terminated || state == State.Terminating
|| inFlightTask.isSkipReadResultDueToCursorRewind()) {
for (Entry entry : entries) {
- inFlightTask.incCompletedEntries();
entry.release();
}
+ boolean resumeReads = state != State.Terminated && state !=
State.Terminating;
+ // Hold the inFlightTasks lock (the same lock doRewindCursor() and
readMoreEntries()'s
+ // read-scheduling path use) so completing the task and rewinding
the cursor here are atomic with
+ // respect to readMoreEntries(), which reads both the free read
slot and the cursor read position
+ // under that lock; otherwise a concurrent read could observe the
freed slot but the stale
+ // (advanced) read position.
+ synchronized (inFlightTasks) {
+ if (resumeReads) {
+ // The discarded read already advanced the cursor read
position past these still-unacked
+ // messages (ManagedCursor advances the read position when
a read completes), which
+ // overwrites the earlier doRewindCursor(). Rewind again
so the messages are re-read;
+ // otherwise they stay stranded behind the read position
and the backlog never drains.
+ // rewind() resets the read position to the mark-delete
position, so only unacked
+ // messages are re-read; entries already acknowledged
(replicated) are skipped on the
+ // re-read. This makes the recovery at-least-once, with
duplicates bounded to the
+ // messages that were in flight when the rewind happened.
+ cursor.rewind();
+ }
+ // Complete the task with an empty result so it releases its
permit and no longer counts
+ // as a pending cursor read. Without this, a pending read that
could not be cancelled
+ // (cursor.cancelPendingReadRequest() returns false for an
already-dispatched read, which
+ // is the common case when there is a backlog) would stay
entries == null forever, keeping
+ // hasPendingRead() permanently true and stalling replication.
+ if (inFlightTask.getEntries() == null) {
+ inFlightTask.setEntries(Collections.emptyList());
+ }
+ }
+ if (resumeReads) {
+ // Resume reading on the broker executor rather than calling
readMoreEntries() directly:
+ // cursor reads can complete inline (cache hit), so a direct
call risks deep
+ // readEntriesComplete -> readMoreEntries recursion
(StackOverflowError), the same hazard
+ //
PersistentDispatcherMultipleConsumers.readMoreEntriesAsync() guards against.
+
topic.getBrokerService().executor().execute(this::readMoreEntries);
+ }
return;
}
diff --git
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java
index a34e86fd118..63f7abbd640 100644
---
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java
+++
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java
@@ -25,6 +25,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
@@ -33,16 +34,20 @@ 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.assertFalse;
import static org.testng.Assert.assertTrue;
import io.netty.channel.EventLoopGroup;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.extern.slf4j.Slf4j;
import org.apache.bookkeeper.mledger.Entry;
@@ -63,11 +68,14 @@ import
org.apache.pulsar.broker.service.persistent.PersistentReplicator.InFlight
import
org.apache.pulsar.broker.service.persistent.PersistentReplicator.ProducerSendCallback;
import
org.apache.pulsar.broker.service.persistent.PersistentReplicator.ReasonOfWaitForCursorRewinding;
import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.Message;
import org.apache.pulsar.client.api.MessageId;
import org.apache.pulsar.client.api.Producer;
import org.apache.pulsar.client.api.ProducerBuilder;
import org.apache.pulsar.client.api.PulsarClientException;
import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.client.api.SubscriptionInitialPosition;
import org.apache.pulsar.client.impl.PulsarClientImpl;
import org.awaitility.Awaitility;
import org.mockito.invocation.InvocationOnMock;
@@ -219,6 +227,328 @@ public class PersistentReplicatorInflightTaskTest extends
OneWayReplicatorTestBa
}
}
+ /**
+ * Reproduces a geo-replication stall on the cursor-rewind path.
+ *
+ * <p>When a cursor rewind happens while a cursor read has already been
dispatched to bookies,
+ * {@code cursor.cancelPendingReadRequest()} returns {@code false} (there
is no registered waiting
+ * read op to cancel), so {@code cancelPendingReadTasks()} only flags the
in-flight task with
+ * {@code skipReadResultDueToCursorRewind=true} without completing it.
When that dispatched read
+ * later completes, {@link PersistentReplicator#readEntriesComplete} hits
the skip branch and must
+ * still complete the task; otherwise the task stays {@code entries ==
null} forever,
+ * {@link PersistentReplicator#hasPendingRead()} stays {@code true}, all
permits remain occupied,
+ * and reads never resume — replication stalls with backlog.
+ */
+ @Test
+ public void testCursorRewindSkippedReadCompletesInFlightTask() throws
Exception {
+ PersistentReplicator replicator = spy(getReplicator(topicName));
+ // Isolate the unit: don't issue a real cursor read, only verify reads
are resumed.
+ doNothing().when(replicator).readMoreEntries();
+
+ LinkedList<InFlightTask> inFlightTasks = replicator.inFlightTasks;
+ List<InFlightTask> originalTasks = new ArrayList<>(inFlightTasks);
+ inFlightTasks.clear();
+
+ try {
+ int fullPermits = replicator.getPermitsIfNoPendingRead();
+ assertTrue(fullPermits > 0, "precondition: replicator should have
free permits");
+
+ // A pending cursor read (entries == null) flagged to be skipped
because of a cursor rewind
+ // whose pending read could not be cancelled
(cancelPendingReadRequest() returned false).
+ InFlightTask task =
+ new InFlightTask(PositionFactory.create(1, 1), 1,
replicator.getReplicatorId());
+ task.setSkipReadResultDueToCursorRewind(true);
+ inFlightTasks.add(task);
+
+ // Precondition: the uncompleted task blocks all reads.
+ assertTrue(replicator.hasPendingRead(), "precondition: task must
look like a pending read");
+ assertEquals(replicator.getPermitsIfNoPendingRead(), 0,
+ "precondition: pending read must occupy all permits");
+
+ // The dispatched read finally completes; its result is discarded
because of the rewind.
+
replicator.readEntriesComplete(Collections.singletonList(mock(Entry.class)),
task);
+
+ // The task must be completed so it no longer blocks replication.
+ assertTrue(task.isDone(), "skipped read must complete the
in-flight task");
+ assertFalse(replicator.hasPendingRead(),
+ "replication must not stay stuck on an uncompleted pending
read");
+ assertEquals(replicator.getPermitsIfNoPendingRead(), fullPermits,
+ "permits must be released after the skipped read
completes");
+ // Reads must be resumed once the slot is freed (dispatched on the
broker executor to
+ // avoid recursing in the read-completion thread).
+ Awaitility.await().untilAsserted(() -> verify(replicator,
atLeastOnce()).readMoreEntries());
+ } finally {
+ inFlightTasks.clear();
+ inFlightTasks.addAll(originalTasks);
+ }
+ }
+
+ /**
+ * End-to-end reproduction of the cursor-rewind stall over a real
two-cluster replication setup.
+ *
+ * <p>A real cursor read is held in flight (entries == null) while the
cursor is rewound the same way
+ * {@link ProducerSendCallback#sendComplete} does on a failed publish to
the remote cluster:
+ * {@code beforeTerminateOrCursorRewinding(Failed_Publishing)} followed by
{@code doRewindCursor(false)}.
+ * Because the read was already dispatched, {@code
cursor.cancelPendingReadRequest()} returns false, so
+ * {@code cancelPendingReadTasks()} only flags the task and does not
complete it. When the dispatched
+ * read then completes through the skip branch, the task must still be
completed and reads resumed —
+ * otherwise the replicator stalls and the backlog is never delivered to
the remote cluster.
+ *
+ * <p>Before the fix this test times out: the task stays {@code entries ==
null}, {@code hasPendingRead()}
+ * stays true, and the backlog never drains.
+ */
+ @Test
+ public void
testReplicationRecoversAfterPublishFailureRewindWithInflightRead() throws
Exception {
+ final String topicName = BrokerTestUtil.newUniqueName("persistent://"
+ nonReplicatedNamespace + "/tp_");
+ final int messageCount = 5;
+ final CountDownLatch readBlocked = new CountDownLatch(1);
+ final CountDownLatch releaseRead = new CountDownLatch(1);
+ final AtomicBoolean blockNextRead = new AtomicBoolean(true);
+ Producer<String> producer = null;
+ Consumer<String> remoteConsumer = null;
+ try {
+ admin1.topics().createNonPartitionedTopic(topicName);
+ admin2.topics().createNonPartitionedTopic(topicName);
+ // Create the verifier subscription on the remote topic up front
so replicated messages are
+ // retained for the end-to-end assertion regardless of retention
policy.
+ admin2.topics().createSubscription(topicName, "e2e-verify",
MessageId.earliest);
+
+ // Produce a backlog before replication starts so the replicator
must read it from the ledger.
+ producer =
client1.newProducer(Schema.STRING).topic(topicName).enableBatching(false).create();
+ for (int i = 0; i < messageCount; i++) {
+ producer.send("msg-" + i);
+ }
+
+ PersistentTopic topic = (PersistentTopic)
pulsar1.getBrokerService().getTopic(topicName, false)
+ .join().get();
+ ManagedLedgerImpl ml = (ManagedLedgerImpl)
topic.getManagedLedger();
+ // Clear the entry cache and intercept ledger reads: block the
first read so it stays in flight
+ // (the in-flight task keeps entries == null), then let it and all
later reads succeed.
+ ManagedLedgerTest.makeReadEntryProbFail(ml, () -> {
+ if (blockNextRead.compareAndSet(true, false)) {
+ readBlocked.countDown();
+ try {
+ releaseRead.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ // Never fail the read; it must complete successfully to reach
the skip branch.
+ return null;
+ });
+
+ // Start replication from earliest; the first read blocks inside
the ledger read (in flight).
+ pulsar1.getConfig().setReplicationStartAt("earliest");
+ admin1.topics().setReplicationClusters(topicName,
Arrays.asList(cluster1, cluster2));
+ assertTrue(readBlocked.await(30, TimeUnit.SECONDS), "the
replicator's cursor read should start");
+
+ PersistentReplicator replicator = (PersistentReplicator)
topic.getReplicators().get(cluster2);
+ Assert.assertNotNull(replicator, "Replicator should not be null");
+ // The dispatched read is in flight and occupies the only read
slot.
+ assertTrue(replicator.hasPendingRead());
+
+ // Rewind the cursor exactly as a failed publish to the remote
cluster does. The in-flight read
+ // was already dispatched, so cancelPendingReadRequest() returns
false and the task is only
+ // flagged for skipping (not completed).
+
replicator.beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Failed_Publishing);
+ replicator.doRewindCursor(false);
+
+ // The in-flight read now completes successfully and is discarded
by the skip branch.
+ releaseRead.countDown();
+
+ // Recovery: the freed slot lets reading resume, so the whole
backlog is replicated and the
+ // cursor mark-delete advances, draining the backlog to 0. Before
the fix the leaked task keeps
+ // getPermitsIfNoPendingRead() == 0, so no read is ever issued and
the backlog stays at
+ // messageCount (this await times out). Note: hasPendingRead() is
intentionally NOT used as the
+ // recovery signal because a healthy idle replicator also holds a
pending "wait for new entries"
+ // read, so it stays true even after a successful recovery.
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
+ assertEquals(replicator.getNumberOfEntriesInBacklog(), 0,
+ "replication backlog must drain after recovery"));
+
+ // End-to-end: verify every produced message is delivered to the
remote cluster, with no loss
+ // and (in this single-batch scenario, where the discarded read
sent nothing) no duplicates.
+ remoteConsumer =
client2.newConsumer(Schema.STRING).topic(topicName)
+ .subscriptionName("e2e-verify")
+
.subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
+ .subscribe();
+ List<String> deliveredValues = new ArrayList<>();
+ for (int i = 0; i < messageCount; i++) {
+ Message<String> received = remoteConsumer.receive(30,
TimeUnit.SECONDS);
+ Assert.assertNotNull(received, "remote cluster should receive
replicated message " + i);
+ deliveredValues.add(received.getValue());
+ remoteConsumer.acknowledge(received);
+ }
+ // No duplicate/extra messages were replicated on the
rewind/recovery path.
+ Assert.assertNull(remoteConsumer.receive(3, TimeUnit.SECONDS),
+ "no duplicate messages should be replicated after
recovery");
+ Set<String> expectedValues = new HashSet<>();
+ for (int i = 0; i < messageCount; i++) {
+ expectedValues.add("msg-" + i);
+ }
+ assertEquals(new HashSet<>(deliveredValues), expectedValues,
+ "every produced message must be replicated exactly once
(no loss)");
+ } finally {
+ releaseRead.countDown();
+ if (producer != null) {
+ producer.close();
+ }
+ if (remoteConsumer != null) {
+ remoteConsumer.close();
+ }
+ admin1.topics().delete(topicName, true);
+ admin2.topics().delete(topicName, true);
+ }
+ }
+
+ /**
+ * End-to-end reproduction of the cursor-rewind stall on the schema-fetch
path.
+ *
+ * <p>{@link GeoPersistentReplicator#replicateEntries} rewinds the cursor
when it reaches a message
+ * whose schema is not yet available locally: it calls
+ * {@code beforeTerminateOrCursorRewinding(Fetching_Schema)} synchronously
and, once the schema has
+ * been fetched, {@code doRewindCursor(true)} (which rewinds the cursor
and resumes reads). If a
+ * cursor read was already dispatched when the schema-fetch rewind happens,
+ * {@code cursor.cancelPendingReadRequest()} returns false, so the
in-flight task is only flagged
+ * for skipping and is not completed. When that dispatched read later
completes through the
+ * {@link PersistentReplicator#readEntriesComplete} skip branch it must
still be completed and reads
+ * resumed; otherwise the task stays {@code entries == null}, {@link
+ * PersistentReplicator#hasPendingRead()} stays true, all permits remain
occupied and replication
+ * stalls with backlog. This is the same root cause as
+ * {@link
#testReplicationRecoversAfterPublishFailureRewindWithInflightRead}, reached
through the
+ * {@code Fetching_Schema} rewind reason (with {@code
doRewindCursor(true)}) instead of
+ * {@code Failed_Publishing}; it is the stall observed as the flaky
+ * {@code ReplicatorTest.testReplicationWithSchema}.
+ *
+ * <p>The schema-fetch rewind is driven directly (rather than by crossing
a real schema boundary)
+ * because the bug requires a cursor read to be in flight at the exact
moment the rewind runs. On
+ * the natural path that needs a read to be pipelined — dispatched by an
earlier message's
+ * {@code sendComplete} — concurrently with {@code replicateEntries}
reaching the schema boundary,
+ * an inherently racy interleaving that cannot be reproduced
deterministically (which is why
+ * {@code testReplicationWithSchema} only fails intermittently). This test
issues the same two
+ * rewind calls ({@code beforeTerminateOrCursorRewinding(Fetching_Schema)}
then
+ * {@code doRewindCursor(true)}) that {@code
GeoPersistentReplicator.replicateEntries} issues at a
+ * schema boundary; in production they are separated by the asynchronous
schema fetch and the
+ * stranded read is a separately-pipelined one, so this test collapses
that timing into a
+ * deterministic sequence on a single held-in-flight read. It therefore
guards the
+ * {@code readEntriesComplete} skip-branch recovery for the {@code
Fetching_Schema} rewind, not the
+ * schema-detection wiring in {@code replicateEntries} itself.
+ *
+ * <p>Before the fix this test times out: the task stays {@code entries ==
null} and the backlog
+ * never drains.
+ */
+ @Test
+ public void
testReplicationRecoversAfterSchemaFetchRewindWithInflightRead() throws
Exception {
+ final String topicName = BrokerTestUtil.newUniqueName("persistent://"
+ nonReplicatedNamespace + "/tp_");
+ final int messageCount = 5;
+ final CountDownLatch readBlocked = new CountDownLatch(1);
+ final CountDownLatch releaseRead = new CountDownLatch(1);
+ final AtomicBoolean blockNextRead = new AtomicBoolean(true);
+ // Capture and restore the shared per-class broker config so this
method does not leak
+ // "earliest" into sibling tests that rely on the default
replicationStartAt.
+ final String prevReplicationStartAt =
pulsar1.getConfig().getReplicationStartAt();
+ Producer<String> producer = null;
+ Consumer<String> remoteConsumer = null;
+ try {
+ admin1.topics().createNonPartitionedTopic(topicName);
+ admin2.topics().createNonPartitionedTopic(topicName);
+ // Create the verifier subscription on the remote topic up front
so replicated messages are
+ // retained for the end-to-end assertion regardless of retention
policy.
+ admin2.topics().createSubscription(topicName, "e2e-verify",
MessageId.earliest);
+
+ // Produce a backlog before replication starts so the replicator
must read it from the ledger.
+ producer =
client1.newProducer(Schema.STRING).topic(topicName).enableBatching(false).create();
+ for (int i = 0; i < messageCount; i++) {
+ producer.send("msg-" + i);
+ }
+
+ PersistentTopic topic = (PersistentTopic)
pulsar1.getBrokerService().getTopic(topicName, false)
+ .join().get();
+ ManagedLedgerImpl ml = (ManagedLedgerImpl)
topic.getManagedLedger();
+ // Clear the entry cache and intercept ledger reads: block the
first read so it stays in flight
+ // (the in-flight task keeps entries == null), then let it and all
later reads succeed.
+ ManagedLedgerTest.makeReadEntryProbFail(ml, () -> {
+ if (blockNextRead.compareAndSet(true, false)) {
+ readBlocked.countDown();
+ try {
+ releaseRead.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ // Never fail the read; it must complete successfully to reach
the skip branch.
+ return null;
+ });
+
+ // Start replication from earliest; the first read blocks inside
the ledger read (in flight).
+ pulsar1.getConfig().setReplicationStartAt("earliest");
+ admin1.topics().setReplicationClusters(topicName,
Arrays.asList(cluster1, cluster2));
+ assertTrue(readBlocked.await(30, TimeUnit.SECONDS), "the
replicator's cursor read should start");
+
+ PersistentReplicator replicator = (PersistentReplicator)
topic.getReplicators().get(cluster2);
+ Assert.assertNotNull(replicator, "Replicator should not be null");
+ // The dispatched read is in flight and occupies the only read
slot.
+ assertTrue(replicator.hasPendingRead());
+
+ // Rewind the cursor with the same two calls
GeoPersistentReplicator.replicateEntries issues
+ // when it reaches a message whose schema must be fetched from the
local cluster: flag the
+ // in-flight read for skipping
(beforeTerminateOrCursorRewinding(Fetching_Schema)), then, once
+ // the schema has been fetched, rewind and resume reads
(doRewindCursor(true)). The in-flight
+ // read was already dispatched, so cancelPendingReadRequest()
returns false and the task is only
+ // flagged, not completed. doRewindCursor(true)'s
readMoreEntries() is a no-op here because the
+ // still-pending (skip-flagged) read keeps hasPendingRead() true.
+
replicator.beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Fetching_Schema);
+ replicator.doRewindCursor(true);
+
+ // The in-flight read now completes successfully and is discarded
by the skip branch.
+ releaseRead.countDown();
+
+ // Recovery: the freed slot lets reading resume, so the whole
backlog is replicated and the
+ // cursor mark-delete advances, draining the backlog to 0. Before
the fix the leaked task keeps
+ // getPermitsIfNoPendingRead() == 0, so no read is ever issued and
the backlog stays at
+ // messageCount (this await times out). Note: hasPendingRead() is
intentionally NOT used as the
+ // recovery signal because a healthy idle replicator also holds a
pending "wait for new entries"
+ // read, so it stays true even after a successful recovery.
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
+ assertEquals(replicator.getNumberOfEntriesInBacklog(), 0,
+ "replication backlog must drain after recovery"));
+
+ // End-to-end: verify every produced message is delivered to the
remote cluster, with no loss
+ // and (in this single-batch scenario, where the discarded read
sent nothing) no duplicates.
+ remoteConsumer =
client2.newConsumer(Schema.STRING).topic(topicName)
+ .subscriptionName("e2e-verify")
+
.subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
+ .subscribe();
+ List<String> deliveredValues = new ArrayList<>();
+ for (int i = 0; i < messageCount; i++) {
+ Message<String> received = remoteConsumer.receive(30,
TimeUnit.SECONDS);
+ Assert.assertNotNull(received, "remote cluster should receive
replicated message " + i);
+ deliveredValues.add(received.getValue());
+ remoteConsumer.acknowledge(received);
+ }
+ // No duplicate/extra messages were replicated on the
rewind/recovery path.
+ Assert.assertNull(remoteConsumer.receive(3, TimeUnit.SECONDS),
+ "no duplicate messages should be replicated after
recovery");
+ Set<String> expectedValues = new HashSet<>();
+ for (int i = 0; i < messageCount; i++) {
+ expectedValues.add("msg-" + i);
+ }
+ assertEquals(new HashSet<>(deliveredValues), expectedValues,
+ "every produced message must be replicated exactly once
(no loss)");
+ } finally {
+ releaseRead.countDown();
+ pulsar1.getConfig().setReplicationStartAt(prevReplicationStartAt);
+ if (producer != null) {
+ producer.close();
+ }
+ if (remoteConsumer != null) {
+ remoteConsumer.close();
+ }
+ admin1.topics().delete(topicName, true);
+ admin2.topics().delete(topicName, true);
+ }
+ }
+
@DataProvider
public Object[][] readSchedulingLimits() {
return new Object[][] {