[fix][client] Support nack, ackTimeout redelivery and dlq for chunked messages (#604)
diff --git a/lib/ConsumerImpl.cc b/lib/ConsumerImpl.cc
index 3337e81..657e1e6 100644
--- a/lib/ConsumerImpl.cc
+++ b/lib/ConsumerImpl.cc
@@ -1490,7 +1490,16 @@
void ConsumerImpl::negativeAcknowledge(const MessageId& messageId) {
unAckedMessageTrackerPtr_->remove(messageId);
- negativeAcksTracker_->add(messageId);
+ // If it's a ChunkMessageId, expand all chunk entries and nack them individually,
+ // so that the broker can redeliver all chunks
+ if (auto chunkMessageId =
+ std::dynamic_pointer_cast<ChunkMessageIdImpl>(Commands::getMessageIdImpl(messageId))) {
+ for (const auto& chunkId : chunkMessageId->getChunkedMessageIds()) {
+ negativeAcksTracker_->add(chunkId);
+ }
+ } else {
+ negativeAcksTracker_->add(messageId);
+ }
}
void ConsumerImpl::disconnectConsumer() { disconnectConsumer(std::nullopt); }
@@ -1651,7 +1660,19 @@
ClientConnectionPtr cnx = getCnx().lock();
if (cnx) {
if (cnx->getServerProtocolVersion() >= proto::v2) {
- cnx->sendCommand(Commands::newRedeliverUnacknowledgedMessages(consumerId_, messageIds));
+ // Expand ChunkMessageIds into all chunk entries to ensure the broker
+ // can redeliver all chunks
+ std::set<MessageId> expandedMsgIds;
+ for (const auto& msgId : messageIds) {
+ if (auto chunkMsgId =
+ std::dynamic_pointer_cast<ChunkMessageIdImpl>(Commands::getMessageIdImpl(msgId))) {
+ const auto& chunkIds = chunkMsgId->getChunkedMessageIds();
+ expandedMsgIds.insert(chunkIds.begin(), chunkIds.end());
+ } else {
+ expandedMsgIds.insert(msgId);
+ }
+ }
+ cnx->sendCommand(Commands::newRedeliverUnacknowledgedMessages(consumerId_, expandedMsgIds));
LOG_DEBUG("Sending RedeliverUnacknowledgedMessages command for Consumer - " << getConsumerId());
}
} else {
@@ -2037,6 +2058,7 @@
producerConfiguration.setSchema(config_.getSchema());
producerConfiguration.setBlockIfQueueFull(false);
producerConfiguration.setBatchingEnabled(false);
+ producerConfiguration.setChunkingEnabled(true);
producerConfiguration.impl_->initialSubscriptionName =
deadLetterPolicy_.getInitialSubscriptionName();
ClientImplPtr client = client_.lock();
diff --git a/lib/UnAckedMessageTrackerEnabled.cc b/lib/UnAckedMessageTrackerEnabled.cc
index 1c7b20e..b366460 100644
--- a/lib/UnAckedMessageTrackerEnabled.cc
+++ b/lib/UnAckedMessageTrackerEnabled.cc
@@ -20,6 +20,7 @@
#include <functional>
+#include "ChunkMessageIdImpl.h"
#include "ClientConnection.h"
#include "ClientImpl.h"
#include "ConsumerImplBase.h"
@@ -108,7 +109,18 @@
bool UnAckedMessageTrackerEnabled::add(const MessageId& msgId) {
std::lock_guard<std::recursive_mutex> acquire(lock_);
- auto id = discardBatch(msgId);
+ // For ChunkMessageId, skip discardBatch to preserve the original ChunkMessageIdImpl.
+ //
+ // ChunkMessageIdImpl stores all chunk entries internally (chunkedMessageIds_), and its
+ // ledgerId/entryId are set to the last chunk's position. If discardBatch is applied, a
+ // new plain MessageIdImpl would be created, losing all chunk entries information.
+ //
+ // Although the set/map key only reflects the last chunk's position, the MessageId object
+ // itself retains the full ChunkMessageIdImpl via its impl_ pointer. So when ackTimeout
+ // triggers and the MessageId is passed to redeliverMessages(), it can be expanded into
+ // all chunk entries for redelivery, ensuring the broker redelivers the complete chunked
+ // message.
+ auto id = std::dynamic_pointer_cast<ChunkMessageIdImpl>(msgId.impl_) ? msgId : discardBatch(msgId);
if (messageIdPartitionMap.count(id) == 0) {
std::set<MessageId>& partition = timePartitions.back();
bool emplace = messageIdPartitionMap.emplace(id, partition).second;
@@ -125,7 +137,9 @@
bool UnAckedMessageTrackerEnabled::remove(const MessageId& msgId) {
std::lock_guard<std::recursive_mutex> acquire(lock_);
- auto id = discardBatch(msgId);
+ // Keep consistent with add(): skip discardBatch for ChunkMessageId to ensure
+ // the same key is used for lookup and removal.
+ auto id = std::dynamic_pointer_cast<ChunkMessageIdImpl>(msgId.impl_) ? msgId : discardBatch(msgId);
bool removed = false;
std::map<MessageId, std::set<MessageId>&>::iterator exist = messageIdPartitionMap.find(id);
diff --git a/tests/MessageChunkingTest.cc b/tests/MessageChunkingTest.cc
index 2421d40..aa684ac 100644
--- a/tests/MessageChunkingTest.cc
+++ b/tests/MessageChunkingTest.cc
@@ -18,14 +18,17 @@
*/
#include <gtest/gtest.h>
#include <pulsar/Client.h>
+#include <pulsar/DeadLetterPolicyBuilder.h>
#include <pulsar/MessageIdBuilder.h>
#include <ctime>
#include <random>
+#include <sstream>
#include "PulsarFriend.h"
#include "WaitUtils.h"
#include "lib/ChunkMessageIdImpl.h"
+#include "lib/ConsumerImpl.h"
#include "lib/LogUtils.h"
DECLARE_LOG_OBJECT()
@@ -454,6 +457,153 @@
consumer.close();
}
+// Aligned with Go TestChunkAckAndNAck and Java testNegativeAckChunkedMessage
+TEST_P(MessageChunkingTest, testNegativeAckChunkedMessage) {
+ if (toString(GetParam()) != "None") {
+ return;
+ }
+ const std::string topic =
+ "MessageChunkingTest-testNegativeAckChunkedMessage-" + std::to_string(time(nullptr));
+
+ Consumer consumer;
+ ConsumerConfiguration consumerConf;
+ consumerConf.setConsumerType(ConsumerShared);
+ consumerConf.setNegativeAckRedeliveryDelayMs(1000);
+ createConsumer(topic, consumer, consumerConf);
+
+ Producer producer;
+ createProducer(topic, producer);
+
+ // Send a chunked message
+ MessageId sendMsgId;
+ ASSERT_EQ(ResultOk, producer.send(MessageBuilder().setContent(largeMessage).build(), sendMsgId));
+
+ // Receive and nack
+ Message msg;
+ ASSERT_EQ(ResultOk, consumer.receive(msg, 5000));
+ ASSERT_EQ(msg.getDataAsString(), largeMessage);
+ consumer.negativeAcknowledge(msg);
+
+ // The message should be redelivered after nack delay
+ Message redeliveredMsg;
+ ASSERT_EQ(ResultOk, consumer.receive(redeliveredMsg, 5000));
+ ASSERT_EQ(redeliveredMsg.getDataAsString(), largeMessage);
+ consumer.acknowledge(redeliveredMsg);
+
+ // Verify no more messages
+ Message noMsg;
+ ASSERT_NE(ResultOk, consumer.receive(noMsg, 2000));
+
+ producer.close();
+ consumer.close();
+}
+
+// Aligned with Java testLargeMessageAckTimeOut
+TEST_P(MessageChunkingTest, testAckTimeoutChunkedMessage) {
+ if (toString(GetParam()) != "None") {
+ return;
+ }
+ const std::string topic =
+ "MessageChunkingTest-testAckTimeoutChunkedMessage-" + std::to_string(time(nullptr));
+
+ Consumer consumer;
+ ConsumerConfiguration consumerConf;
+ consumerConf.setConsumerType(ConsumerShared);
+ // Set ack timeout to 2 seconds
+ PulsarFriend::setConsumerUnAckMessagesTimeoutMs(consumerConf, 2000);
+ createConsumer(topic, consumer, consumerConf);
+
+ Producer producer;
+ createProducer(topic, producer);
+
+ // Send a chunked message
+ MessageId sendMsgId;
+ ASSERT_EQ(ResultOk, producer.send(MessageBuilder().setContent(largeMessage).build(), sendMsgId));
+
+ // Receive but do NOT acknowledge - let ack timeout trigger redelivery
+ Message msg;
+ ASSERT_EQ(ResultOk, consumer.receive(msg, 5000));
+ ASSERT_EQ(msg.getDataAsString(), largeMessage);
+
+ // Wait for ack timeout to trigger redelivery
+ // The message should be redelivered after ack timeout (2s)
+ Message redeliveredMsg;
+ ASSERT_EQ(ResultOk, consumer.receive(redeliveredMsg, 5000));
+ ASSERT_EQ(redeliveredMsg.getDataAsString(), largeMessage);
+ consumer.acknowledge(redeliveredMsg);
+
+ // Verify no more messages
+ Message noMsg;
+ ASSERT_NE(ResultOk, consumer.receive(noMsg, 2000));
+
+ producer.close();
+ consumer.close();
+}
+
+TEST_P(MessageChunkingTest, testChunkedMessageDLQ) {
+ if (toString(GetParam()) != "None") {
+ return;
+ }
+ const std::string topic = "persistent://public/default/MessageChunkingTest-testChunkedMessageDLQ-" +
+ std::to_string(time(nullptr));
+ const std::string subName = "my-sub";
+ const std::string dlqTopic = topic + "-" + subName + "-DLQ";
+
+ Client client(lookupUrl);
+
+ auto dlqPolicy =
+ DeadLetterPolicyBuilder().maxRedeliverCount(2).initialSubscriptionName("dlq-init-sub").build();
+
+ Consumer consumer;
+ ConsumerConfiguration consumerConf;
+ consumerConf.setConsumerType(ConsumerShared);
+ consumerConf.setNegativeAckRedeliveryDelayMs(100);
+ consumerConf.setDeadLetterPolicy(dlqPolicy);
+ ASSERT_EQ(ResultOk, client.subscribe(topic, subName, consumerConf, consumer));
+
+ // Subscribe to DLQ topic to verify messages arrive there
+ Consumer dlqConsumer;
+ ConsumerConfiguration dlqConsumerConf;
+ dlqConsumerConf.setConsumerType(ConsumerShared);
+ ASSERT_EQ(ResultOk, client.subscribe(dlqTopic, "dlq-sub", dlqConsumerConf, dlqConsumer));
+
+ Producer producer;
+ createProducer(topic, producer);
+
+ // Send a chunked message
+ MessageId sendMsgId;
+ ASSERT_EQ(ResultOk, producer.send(MessageBuilder().setContent(largeMessage).build(), sendMsgId));
+
+ // Nack the message maxRedeliverCount + 1 times to trigger DLQ
+ Message msg;
+ for (int i = 0; i < dlqPolicy.getMaxRedeliverCount() + 1; i++) {
+ ASSERT_EQ(ResultOk, consumer.receive(msg, 5000));
+ ASSERT_EQ(msg.getDataAsString(), largeMessage);
+ consumer.negativeAcknowledge(msg);
+ }
+
+ // Verify the message arrives in DLQ with correct content
+ Message dlqMsg;
+ ASSERT_EQ(ResultOk, dlqConsumer.receive(dlqMsg, 10000));
+ ASSERT_EQ(dlqMsg.getDataAsString(), largeMessage);
+ std::stringstream expectedOriginMsgId;
+ expectedOriginMsgId << sendMsgId;
+ ASSERT_EQ(dlqMsg.getProperty(PROPERTY_ORIGIN_MESSAGE_ID), expectedOriginMsgId.str());
+ ASSERT_EQ(dlqMsg.getProperty(SYSTEM_PROPERTY_REAL_TOPIC), topic);
+
+ // Verify no more messages in DLQ
+ Message noMsg;
+ ASSERT_NE(ResultOk, dlqConsumer.receive(noMsg, 2000));
+
+ // Verify original consumer has no more messages (message was acked after DLQ send)
+ ASSERT_NE(ResultOk, consumer.receive(noMsg, 2000));
+
+ producer.close();
+ consumer.close();
+ dlqConsumer.close();
+ client.close();
+}
+
// The CI env is Ubuntu 16.04, the gtest-dev version is 1.8.0 that doesn't have INSTANTIATE_TEST_SUITE_P
INSTANTIATE_TEST_CASE_P(Pulsar, MessageChunkingTest,
::testing::Values(CompressionNone, CompressionLZ4, CompressionZLib, CompressionZSTD,