You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2026/07/25 14:17:59 UTC
(pulsar) 03/11: [fix][broker] Trigger max read position callback for messages published during transaction buffer recovery (#26234)
This is an automated email from the ASF dual-hosted git repository.
lhotari pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/pulsar.git
commit d7a7a6881fb7dfcd13634bc174bfc60b8f4650eb
Author: Lari Hotari <lh...@users.noreply.github.com>
AuthorDate: Thu Jul 23 19:15:23 2026 +0300
[fix][broker] Trigger max read position callback for messages published during transaction buffer recovery (#26234)
(cherry picked from commit 520b0daa3f632e283930624bf29e7a5b4ed45828)
---
.../buffer/impl/TopicTransactionBuffer.java | 21 ++-
.../impl/TopicTransactionBufferRecoveryTest.java | 144 +++++++++++++++++++++
2 files changed, 163 insertions(+), 2 deletions(-)
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java
index 7ae311a54639..aad9862cf4fa 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java
@@ -150,7 +150,7 @@ public class TopicTransactionBuffer extends TopicTransactionBufferState implemen
public void recoverComplete() {
synchronized (TopicTransactionBuffer.this) {
if (ongoingTxns.isEmpty()) {
- maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry();
+ updateMaxReadPositionAfterRecovery();
}
if (!changeToReadyState()) {
log.error("[{}]Transaction buffer recover fail, current state: {}",
@@ -171,7 +171,7 @@ public class TopicTransactionBuffer extends TopicTransactionBufferState implemen
@Override
public void noNeedToRecover() {
synchronized (TopicTransactionBuffer.this) {
- maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry();
+ updateMaxReadPositionAfterRecovery();
if (!changeToNoSnapshotState()) {
log.error("[{}]Transaction buffer recover fail", topic.getName());
} else {
@@ -627,6 +627,23 @@ public class TopicTransactionBuffer extends TopicTransactionBufferState implemen
}
}
+ /**
+ * Advance the max read position to the last confirmed entry when recovery finishes. While the transaction
+ * buffer is recovering, {@link #syncMaxReadPositionForNormalPublish(Position, boolean)} ignores publishes, so
+ * messages published during recovery would otherwise never trigger the maxReadPositionMovedForward callback.
+ * {@link PersistentTopic} uses that callback to maintain lastMaxReadPositionMovedForwardTimestamp, which
+ * ReplicatedSubscriptionsController relies on to detect new data when deciding whether to start a snapshot.
+ * Must be called while synchronized on this transaction buffer.
+ */
+ private void updateMaxReadPositionAfterRecovery() {
+ Position preMaxReadPosition = this.maxReadPosition;
+ this.maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry();
+ if (this.maxReadPosition != null
+ && (preMaxReadPosition == null || preMaxReadPosition.compareTo(this.maxReadPosition) < 0)) {
+ this.maxReadPositionCallBack.maxReadPositionMovedForward(preMaxReadPosition, this.maxReadPosition);
+ }
+ }
+
@Override
public CompletableFuture<Void> purgeTxns(List<Long> dataLedgers) {
return null;
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java
new file mode 100644
index 000000000000..101f69b263f8
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.transaction.buffer.impl;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import lombok.Cleanup;
+import org.apache.bookkeeper.mledger.Position;
+import org.apache.bookkeeper.mledger.PositionFactory;
+import org.apache.pulsar.broker.BrokerTestUtil;
+import org.apache.pulsar.broker.service.persistent.PersistentTopic;
+import org.apache.pulsar.broker.transaction.buffer.AbortedTxnProcessor;
+import org.apache.pulsar.broker.transaction.buffer.TransactionBufferProvider;
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.ProducerConsumerBase;
+import org.awaitility.Awaitility;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link TopicTransactionBuffer} recovery completion behavior.
+ */
+@Test(groups = "broker")
+public class TopicTransactionBufferRecoveryTest extends ProducerConsumerBase {
+
+ @BeforeClass(alwaysRun = true)
+ @Override
+ protected void setup() throws Exception {
+ conf.setTransactionCoordinatorEnabled(true);
+ super.internalSetup();
+ super.producerBaseSetup();
+ }
+
+ @AfterClass(alwaysRun = true)
+ @Override
+ protected void cleanup() throws Exception {
+ super.internalCleanup();
+ }
+
+ @DataProvider(name = "snapshotExists")
+ public Object[][] snapshotExists() {
+ return new Object[][] { { false }, { true } };
+ }
+
+ /**
+ * While the transaction buffer is recovering, normal publishes don't move the max read position, so they
+ * don't update the topic's lastMaxReadPositionMovedForwardTimestamp either. When recovery completes, the
+ * transaction buffer must account for the messages published during recovery and trigger the
+ * maxReadPositionMovedForward callback; otherwise ReplicatedSubscriptionsController would consider the topic
+ * to have no new data and never start a subscription snapshot until further traffic arrives.
+ */
+ @Test(dataProvider = "snapshotExists")
+ public void testMaxReadPositionMovedForwardForMessagesPublishedDuringRecovery(boolean snapshotExists)
+ throws Exception {
+ String tpName = BrokerTestUtil.newUniqueName("persistent://public/default/tp-tb-recovery");
+ CompletableFuture<Position> recoverFuture = new CompletableFuture<>();
+ TransactionBufferProvider originalProvider = pulsar.getTransactionBufferProvider();
+ pulsar.setTransactionBufferProvider(originTopic -> {
+ AbortedTxnProcessor processor = mock(AbortedTxnProcessor.class);
+ when(processor.recoverFromSnapshot()).thenReturn(recoverFuture);
+ when(processor.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
+ return new TopicTransactionBuffer(
+ (PersistentTopic) originTopic, processor, AbortedTxnProcessor.SnapshotType.Single);
+ });
+ try {
+ @Cleanup
+ Producer<byte[]> producer = pulsarClient.newProducer().topic(tpName).create();
+ // The transaction buffer is stuck in the recovering state until recoverFuture completes, so these
+ // messages are published while it is still recovering.
+ for (int i = 0; i < 3; i++) {
+ producer.send(("msg-" + i).getBytes(StandardCharsets.UTF_8));
+ }
+ PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService()
+ .getTopicIfExists(tpName).get().orElseThrow();
+ assertEquals(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp(), 0L,
+ "Publishes during recovery are not expected to move the max read position forward");
+
+ // Let the recovery finish, either replaying from a snapshot position or without any snapshot.
+ recoverFuture.complete(snapshotExists ? PositionFactory.EARLIEST : null);
+
+ Awaitility.await().untilAsserted(() -> {
+ assertTrue(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp() > 0,
+ "Completed recovery should move the max read position forward for the messages"
+ + " published during recovery");
+ assertEquals(persistentTopic.getTransactionBuffer().getMaxReadPosition(),
+ persistentTopic.getManagedLedger().getLastConfirmedEntry());
+ });
+ } finally {
+ pulsar.setTransactionBufferProvider(originalProvider);
+ }
+ }
+
+ /**
+ * When nothing is published while the transaction buffer recovers, completing the recovery must not trigger
+ * the maxReadPositionMovedForward callback, so an idle topic isn't mistaken for one with new data.
+ */
+ @Test
+ public void testMaxReadPositionNotMovedForwardWhenNothingPublishedDuringRecovery() throws Exception {
+ String tpName = BrokerTestUtil.newUniqueName("persistent://public/default/tp-tb-recovery-idle");
+ CompletableFuture<Position> recoverFuture = new CompletableFuture<>();
+ TransactionBufferProvider originalProvider = pulsar.getTransactionBufferProvider();
+ pulsar.setTransactionBufferProvider(originTopic -> {
+ AbortedTxnProcessor processor = mock(AbortedTxnProcessor.class);
+ when(processor.recoverFromSnapshot()).thenReturn(recoverFuture);
+ when(processor.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
+ return new TopicTransactionBuffer(
+ (PersistentTopic) originTopic, processor, AbortedTxnProcessor.SnapshotType.Single);
+ });
+ try {
+ PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService()
+ .getTopic(tpName, true).get().orElseThrow();
+ recoverFuture.complete(null);
+ Awaitility.await().untilAsserted(() ->
+ assertEquals(persistentTopic.getTransactionBuffer().getMaxReadPosition(),
+ persistentTopic.getManagedLedger().getLastConfirmedEntry()));
+ assertEquals(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp(), 0L,
+ "Recovery of an idle topic should not move the max read position forward");
+ } finally {
+ pulsar.setTransactionBufferProvider(originalProvider);
+ }
+ }
+}