Skip to content

Commit 855c60d

Browse files
authored
[improve] Refactor processMessageChunk to handle ack holes and out-of-order chunks (#587)
Master Issue: apache/pulsar#13627 Related Issue: apache/pulsar#21070 and apache/pulsar#21101 ### Motivation apache/pulsar#21070 and apache/pulsar#21101 fixed two critical issues in the Java client's chunked message handling: 1. **Unable to reassemble chunked messages after redeliver**: When a chunked message is redelivered (e.g., due to broker unload or reconnect), the consumer receives duplicated chunks. The old code could not handle this correctly: - For duplicated first chunk (chunkId=0): the old context was not properly cleaned up and restarted, causing the message to never be assembled. - For duplicated middle chunks: the chunk would be rejected (since chunkId ≤ lastChunkedMessageId), and the old code would discard the context entirely, making the message unrecoverable. 2. **Ack holes caused by corrupted or orphaned chunks**: When a different producer reuses the same uuid (corrupted chunk scenario), or when chunk context is discarded due to gap/expiration, the stale cached chunks or the incoming corrupted chunks were never acknowledged. This causes the broker subscription cursor to get stuck, leading to message backlog accumulation that never drains — even after all logically valid messages have been consumed and acknowledged. These PRs added logic to distinguish between redeliver (same messageId) and corruption (different messageId), allowing the consumer to correctly restart chunk assembly on redeliver while acking stale chunks on corruption to prevent ack holes. The C++ client had the same issues. This PR ports the equivalent logic to ensure consistent behavior across all client implementations. **Note**: Currently, after a chunked message is assembled, the ackTimeout and nack logic only tracks/handles the last chunk message (i.e., the final messageId of the assembled message). This means if ackTimeout or nack triggers a redeliver, only the last chunk entry is redelivered rather than all chunk entries. This limitation needs to be addressed in a follow-up PR. ### Modifications **Core logic changes in `ConsumerImpl.cc` (`processMessageChunk`)**: - **Part 1 (chunkId == 0)**: When receiving a duplicated first chunk for a uuid that already has an incomplete context, detect whether it's a redeliver (same messageId in cache) or corruption (different messageId). For redeliver: remove old context and restart assembling. For corruption: ack all cached chunks to avoid ack holes, then restart. - **Part 3 (duplicated middle chunk)**: When receiving a chunk with chunkId ≤ lastChunkedMessageId, detect whether it's a redeliver or corruption. For redeliver: simply discard the duplicate and continue waiting for the next expected chunk. For corruption: ack the corrupted chunk to avoid ack holes. - **Part 3 (gap chunk)**: When receiving a chunk that skips expected sequence numbers, ack the chunk if it has expired to avoid ack holes. - **Removed `trackMessage` calls for discarded chunks**: The old code called `trackMessage(messageId)` for orphaned/invalid chunks (Part 2 and old Part 3), which would add the single chunk entry to the `UnAckedMessageTracker`. When ackTimeout triggered, it would redeliver only that single chunk entry — but this is pointless because the consumer still cannot assemble a complete chunked message from a single chunk, and the redelivered chunk would just enter the same discard path again in an infinite loop. - Added `LOG_WARN` and `LOG_INFO` for observability across all scenarios. - Added detailed comments explaining each part of the chunk processing logic with examples. **Test changes in `MessageChunkingTest.cc`**: - Added `testResendChunkMessagesWithoutAckHole`: Verifies that resending the first chunk (chunkId=0) allows correct reassembly without ack holes. - Added `testResendChunkMessages`: Verifies interleaved chunk resends across multiple uuids assemble correctly. - Added `testResendChunkWithAckHoleMessages`: Verifies duplicated middle chunks are filtered correctly and chunk gaps cause context cleanup. - Refactored existing tests to reuse the `sendSingleChunk` helper function for better readability.
1 parent 9c6a842 commit 855c60d

2 files changed

Lines changed: 296 additions & 60 deletions

File tree

lib/ConsumerImpl.cc

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -488,19 +488,70 @@ optional<SharedBuffer> ConsumerImpl::processMessageChunk(const SharedBuffer& pay
488488
LOG_DEBUG("Process message chunk (chunkId: " << chunkId << ", uuid: " << uuid
489489
<< ", messageId: " << messageId << ") of "
490490
<< payload.readableBytes() << " bytes");
491-
492491
Lock lock(chunkProcessMutex_);
493-
492+
// For non-last chunks, increase available permits immediately since they don't occupy receiver queue.
493+
if (chunkId != metadata.num_chunks_from_msg() - 1) {
494+
increaseAvailablePermits(cnx);
495+
}
494496
// Lazy task scheduling to expire incomplete chunk message
495497
bool expected = false;
496498
if (expireTimeOfIncompleteChunkedMessageMs_ > 0 &&
497499
expireChunkMessageTaskScheduled_.compare_exchange_strong(expected, true)) {
498500
triggerCheckExpiredChunkedTimer();
499501
}
500502

503+
// Part 1: chunkId == 0, this is the first chunk of a chunked message.
504+
// If a previous incomplete context with the same uuid exists, it means either:
505+
// a) Message redeliver: the first chunk's messageId matches one of the cached chunk messageIds.
506+
// In this case, the old context is simply removed and a new context is created to restart
507+
// assembling from scratch. No ack is needed since the old chunks will be redelivered.
508+
// b) Corrupted chunk message: the first chunk's messageId does NOT match any cached chunk messageId,
509+
// meaning a new producer sent a message with the same uuid. In this case, ack the old cached
510+
// chunks to avoid ack holes, then remove the old context and create a new one.
511+
// After handling the old context, check maxPendingChunkedMessage limit and create a new context.
501512
auto it = chunkedMessageCache_.find(uuid);
502-
503-
if (chunkId == 0 && it == chunkedMessageCache_.end()) {
513+
if (chunkId == 0) {
514+
// Handle ack hole when receiving duplicated first chunk.
515+
// For example (message redeliver):
516+
// Chunk-1 sequence ID: 0, chunk ID: 0, msgID: 1:1
517+
// Chunk-2 sequence ID: 0, chunk ID: 1, msgID: 1:2
518+
// Chunk-3 sequence ID: 0, chunk ID: 0, msgID: 1:1
519+
// Chunk-4 sequence ID: 0, chunk ID: 1, msgID: 1:2
520+
// Chunk-5 sequence ID: 0, chunk ID: 2, msgID: 1:3
521+
// For example (corrupted chunk message):
522+
// Chunk-1 sequence ID: 0, chunk ID: 0, msgID: 1:1
523+
// Chunk-2 sequence ID: 0, chunk ID: 1, msgID: 1:2
524+
// Chunk-3 sequence ID: 0, chunk ID: 0, msgID: 1:3
525+
// Chunk-4 sequence ID: 0, chunk ID: 1, msgID: 1:4
526+
// Chunk-5 sequence ID: 0, chunk ID: 2, msgID: 1:5
527+
if (it != chunkedMessageCache_.end()) {
528+
auto& existingCtx = it->second;
529+
bool isCorruptedChunkMessage = true;
530+
for (const MessageId& cachedMsgId : existingCtx.getChunkedMessageIds()) {
531+
if (cachedMsgId.ledgerId() == messageId.ledgerId() &&
532+
cachedMsgId.entryId() == messageId.entryId()) {
533+
isCorruptedChunkMessage = false;
534+
break;
535+
}
536+
}
537+
if (isCorruptedChunkMessage) {
538+
for (const MessageId& cachedMsgId : existingCtx.getChunkedMessageIds()) {
539+
LOG_INFO("Acking corrupted chunk message to avoid ack hole, uuid: "
540+
<< uuid << ", messageId: " << cachedMsgId);
541+
acknowledgeAsync(cachedMsgId, [uuid, cachedMsgId](Result result) {
542+
if (result != ResultOk) {
543+
LOG_ERROR("Failed to acknowledge corrupted chunk, uuid: "
544+
<< uuid << ", messageId: " << cachedMsgId);
545+
}
546+
});
547+
}
548+
}
549+
LOG_WARN("Received a duplicated first chunk (uuid: "
550+
<< uuid << ", messageId: " << messageId
551+
<< "). Remove previous chunk context and restart assembling");
552+
chunkedMessageCache_.remove(uuid);
553+
it = chunkedMessageCache_.end();
554+
}
504555
if (maxPendingChunkedMessage_ > 0 && chunkedMessageCache_.size() >= maxPendingChunkedMessage_) {
505556
chunkedMessageCache_.removeOldestValues(
506557
chunkedMessageCache_.size() - maxPendingChunkedMessage_ + 1,
@@ -512,47 +563,134 @@ optional<SharedBuffer> ConsumerImpl::processMessageChunk(const SharedBuffer& pay
512563
}
513564
it = chunkedMessageCache_.putIfAbsent(
514565
uuid, ChunkedMessageCtx{metadata.num_chunks_from_msg(), metadata.total_chunk_msg_size()});
566+
it->second.appendChunk(messageId, payload);
567+
lock.unlock();
568+
return {};
515569
}
516570

517-
auto& chunkedMsgCtx = it->second;
518-
if (it == chunkedMessageCache_.end() || !chunkedMsgCtx.validateChunkId(chunkId)) {
571+
// Part 2: chunkId != 0 but chunk context not found in cache.
572+
// This happens when the first chunk was not received (e.g., consumer used seek() or started
573+
// consuming from a specific message position that falls in the middle of a chunked message,
574+
// or the context was evicted due to maxPendingChunkedMessage limit).
575+
// In this case, the chunk message cannot be assembled, so just discard it.
576+
if (it == chunkedMessageCache_.end()) {
519577
auto startMessageId = getStartMessageId();
520578
if (!config_.isStartMessageIdInclusive() && startMessageId &&
521579
startMessageId->ledgerId() == messageId.ledgerId() &&
522580
startMessageId->entryId() == messageId.entryId()) {
523-
// When the start message id is not inclusive, the last chunk of the previous chunked message will
524-
// be delivered, which is expected and we only need to filter it out.
525-
chunkedMessageCache_.remove(uuid);
526581
LOG_INFO("Filtered the chunked message before the start message id (uuid: "
527582
<< uuid << " chunkId: " << chunkId << ", messageId: " << messageId << ")");
528-
} else if (it == chunkedMessageCache_.end()) {
583+
} else {
529584
LOG_ERROR("Received an uncached chunk (uuid: " << uuid << " chunkId: " << chunkId
530585
<< ", messageId: " << messageId << ")");
586+
}
587+
// If this is the last chunk, its permit was not returned at the entry of processMessageChunk,
588+
// so we need to return it here to avoid permit leak.
589+
if (chunkId == metadata.num_chunks_from_msg() - 1) {
590+
increaseAvailablePermits(cnx);
591+
}
592+
lock.unlock();
593+
// The uncached chunk cannot be assembled into a complete message.
594+
// If the message has expired, acknowledge it directly to avoid ack holes;
595+
// otherwise, track it in the unacked message tracker so it will be redelivered on timeout.
596+
if (expireTimeOfIncompleteChunkedMessageMs_ > 0 &&
597+
TimeUtils::currentTimeMillis() >
598+
static_cast<long>(metadata.publish_time()) + expireTimeOfIncompleteChunkedMessageMs_) {
599+
acknowledgeAsync(messageId, [uuid, messageId](Result result) {
600+
if (result != ResultOk) {
601+
LOG_WARN("Failed to acknowledge uncached chunk, uuid: " << uuid
602+
<< ", messageId: " << messageId);
603+
}
604+
});
531605
} else {
532-
LOG_ERROR("Received a chunk whose chunk id is invalid (uuid: "
533-
<< uuid << " chunkId: " << chunkId << ", messageId: " << messageId << ")");
534-
chunkedMessageCache_.remove(uuid);
606+
trackMessage(messageId);
607+
}
608+
return {};
609+
}
610+
611+
// Part 3: chunkId does not match the expected next chunk ID (out-of-order).
612+
// Two sub-cases:
613+
// a) chunkId <= lastChunkedMessageId: duplicated chunk caused by redeliver or corruption.
614+
// Filter and ack the duplicated chunk if it's corrupted, then discard it.
615+
// b) chunkId > lastChunkedMessageId + 1: gap detected, the chunked message is corrupted.
616+
// Remove the context and ack the current chunk if it has expired.
617+
auto& chunkedMsgCtx = it->second;
618+
if (!chunkedMsgCtx.validateChunkId(chunkId)) {
619+
const int lastChunkedMessageId = static_cast<int>(chunkedMsgCtx.getChunkedMessageIds().size()) - 1;
620+
// For example (duplicated chunk):
621+
// Chunk-1 sequence ID: 0, chunk ID: 0, msgID: 1:1
622+
// Chunk-2 sequence ID: 0, chunk ID: 1, msgID: 1:2
623+
// Chunk-3 sequence ID: 0, chunk ID: 2, msgID: 1:3
624+
// Chunk-4 sequence ID: 0, chunk ID: 1, msgID: 1:4
625+
// Chunk-5 sequence ID: 0, chunk ID: 2, msgID: 1:5
626+
// Chunk-6 sequence ID: 0, chunk ID: 3, msgID: 1:6
627+
if (chunkId <= lastChunkedMessageId) {
628+
bool isCorruptedChunk = true;
629+
for (const MessageId& cachedMsgId : chunkedMsgCtx.getChunkedMessageIds()) {
630+
if (cachedMsgId.ledgerId() == messageId.ledgerId() &&
631+
cachedMsgId.entryId() == messageId.entryId()) {
632+
isCorruptedChunk = false;
633+
break;
634+
}
635+
}
636+
LOG_WARN("Received a duplicated chunk message (uuid: "
637+
<< uuid << " chunkId: " << chunkId << ", lastChunkedMessageId: " << lastChunkedMessageId
638+
<< ", messageId: " << messageId << ")");
639+
lock.unlock();
640+
if (isCorruptedChunk) {
641+
LOG_INFO("Acking corrupted duplicated chunk to avoid ack hole, uuid: "
642+
<< uuid << ", messageId: " << messageId);
643+
acknowledgeAsync(messageId, [uuid, messageId](Result result) {
644+
if (result != ResultOk) {
645+
LOG_WARN("Failed to acknowledge duplicated chunk, uuid: " << uuid << ", messageId: "
646+
<< messageId);
647+
}
648+
});
649+
}
650+
return {};
651+
}
652+
// chunkId > lastChunkedMessageId + 1, the chunked message is corrupted.
653+
LOG_WARN("Received unexpected chunk (uuid: " << uuid << " chunkId: " << chunkId
654+
<< ", lastChunkedMessageId: " << lastChunkedMessageId
655+
<< ", messageId: " << messageId << ")");
656+
chunkedMessageCache_.remove(uuid);
657+
// If this is the last chunk, its permit was not returned at the entry of processMessageChunk,
658+
// so we need to return it here to avoid permit leak.
659+
if (chunkId == metadata.num_chunks_from_msg() - 1) {
660+
increaseAvailablePermits(cnx);
535661
}
536662
lock.unlock();
537-
increaseAvailablePermits(cnx);
538-
trackMessage(messageId);
663+
if (expireTimeOfIncompleteChunkedMessageMs_ > 0 &&
664+
TimeUtils::currentTimeMillis() >
665+
static_cast<long>(metadata.publish_time()) + expireTimeOfIncompleteChunkedMessageMs_) {
666+
LOG_INFO("Acking corrupted gap chunk to avoid ack hole, uuid: " << uuid
667+
<< ", messageId: " << messageId);
668+
acknowledgeAsync(messageId, [uuid, messageId](Result result) {
669+
if (result != ResultOk) {
670+
LOG_WARN("Failed to acknowledge gap chunk, uuid: " << uuid
671+
<< ", messageId: " << messageId);
672+
}
673+
});
674+
} else {
675+
trackMessage(messageId);
676+
}
539677
return {};
540678
}
541679

680+
// Part 4: chunkId matches the expected next chunk ID, append chunk payload.
681+
// If all chunks have been received, assemble the full message, build a ChunkMessageId
682+
// containing all individual chunk message IDs, and return the uncompressed payload.
542683
chunkedMsgCtx.appendChunk(messageId, payload);
543684
if (!chunkedMsgCtx.isCompleted()) {
544685
lock.unlock();
545-
increaseAvailablePermits(cnx);
546686
return {};
547687
}
548-
549688
messageId = std::make_shared<ChunkMessageIdImpl>(chunkedMsgCtx.moveChunkedMessageIds())->build();
550-
551689
LOG_DEBUG("Chunked message completed chunkId: " << chunkId << ", ChunkedMessageCtx: " << chunkedMsgCtx
552690
<< ", sequenceId: " << metadata.sequence_id());
553-
554691
auto wholePayload = chunkedMsgCtx.getBuffer();
555692
chunkedMessageCache_.remove(uuid);
693+
lock.unlock();
556694
if (uncompressMessageIfNeeded(cnx, messageIdData, metadata, wholePayload, false)) {
557695
return wholePayload;
558696
} else {

0 commit comments

Comments
 (0)