Skip to content

[fix][client] Fix consumer can't consume resent chunked messages - #21070

Merged
RobertIndie merged 1 commit into
apache:masterfrom
RobertIndie:resend-chunk
Aug 29, 2023
Merged

[fix][client] Fix consumer can't consume resent chunked messages#21070
RobertIndie merged 1 commit into
apache:masterfrom
RobertIndie:resend-chunk

Conversation

@RobertIndie

@RobertIndie RobertIndie commented Aug 26, 2023

Copy link
Copy Markdown
Member

Motivation

Current, when the producer resend the chunked message like this:

  • M1: UUID: 0, ChunkID: 0
  • M2: UUID: 0, ChunkID: 0 // Resend the first chunk
  • M3: UUID: 0, ChunkID: 1

When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:

if (msgMetadata.getChunkId() == 0) {
if (chunkedMsgCtx != null) {
// The first chunk of a new chunked-message received before receiving other chunks of previous
// chunked-message
// so, remove previous chunked-message from map and release buffer
if (chunkedMsgCtx.chunkedMsgBuffer != null) {
ReferenceCountUtil.safeRelease(chunkedMsgCtx.chunkedMsgBuffer);
}
chunkedMsgCtx.recycle();
chunkedMessagesMap.remove(msgMetadata.getUuid());
}
pendingChunkedMessageCount++;
if (maxPendingChunkedMessage > 0 && pendingChunkedMessageCount > maxPendingChunkedMessage) {
removeOldestPendingChunkedMessage();
}
int totalChunks = msgMetadata.getNumChunksFromMsg();
ByteBuf chunkedMsgBuffer = PulsarByteBufAllocator.DEFAULT.buffer(msgMetadata.getTotalChunkMsgSize(),
msgMetadata.getTotalChunkMsgSize());
chunkedMsgCtx = chunkedMessagesMap.computeIfAbsent(msgMetadata.getUuid(),
(key) -> ChunkedMessageCtx.get(totalChunks, chunkedMsgBuffer));
pendingChunkedMessageUuidQueue.add(msgMetadata.getUuid());
}
// discard message if chunk is out-of-order
if (chunkedMsgCtx == null || chunkedMsgCtx.chunkedMsgBuffer == null
|| msgMetadata.getChunkId() != (chunkedMsgCtx.lastChunkedMessageId + 1)) {
// means we lost the first chunk: should never happen
log.info("Received unexpected chunk messageId {}, last-chunk-id{}, chunkId = {}", msgId,
(chunkedMsgCtx != null ? chunkedMsgCtx.lastChunkedMessageId : null), msgMetadata.getChunkId());
if (chunkedMsgCtx != null) {
if (chunkedMsgCtx.chunkedMsgBuffer != null) {
ReferenceCountUtil.safeRelease(chunkedMsgCtx.chunkedMsgBuffer);
}
chunkedMsgCtx.recycle();
}
chunkedMessagesMap.remove(msgMetadata.getUuid());
compressedPayload.release();
increaseAvailablePermits(cnx);
if (expireTimeOfIncompleteChunkedMessageMillis > 0
&& System.currentTimeMillis() > (msgMetadata.getPublishTime()
+ expireTimeOfIncompleteChunkedMessageMillis)) {
doAcknowledge(msgId, AckType.Individual, Collections.emptyMap(), null);
} else {
trackMessage(msgId);
}
return null;
}

The bug can be easily reproduced using the testcase testResendChunkMessages introduced by this PR.

Modifications

  • When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.

Verifying this change

This change added tests.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

Documentation

  • doc
  • doc-required
  • doc-not-needed
  • doc-complete

Matching PR in forked repository

PR in forked repository:

@github-actions

Copy link
Copy Markdown

@RobertIndie Please add the following content to your PR description and select a checkbox:

- [ ] `doc` <!-- Your PR contains doc changes -->
- [ ] `doc-required` <!-- Your PR changes impact docs and you will update later -->
- [ ] `doc-not-needed` <!-- Your PR changes do not impact docs -->
- [ ] `doc-complete` <!-- Docs have been already added -->

@RobertIndie RobertIndie added doc-not-needed Your PR changes do not impact docs release/3.0.2 and removed doc-label-missing labels Aug 26, 2023
@github-actions github-actions Bot added doc-label-missing and removed doc-not-needed Your PR changes do not impact docs labels Aug 26, 2023
@RobertIndie RobertIndie added release/3.1.1 doc-not-needed Your PR changes do not impact docs and removed doc-label-missing labels Aug 26, 2023
@github-actions github-actions Bot added doc-label-missing and removed doc-not-needed Your PR changes do not impact docs labels Aug 26, 2023
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs and removed doc-label-missing labels Aug 28, 2023
@RobertIndie
RobertIndie merged commit eb2e3a2 into apache:master Aug 29, 2023
@RobertIndie
RobertIndie deleted the resend-chunk branch August 29, 2023 16:23
RobertIndie added a commit that referenced this pull request Aug 31, 2023
)

### Motivation

Current, when the producer resend the chunked message like this:
- M1: UUID: 0, ChunkID: 0
- M2: UUID: 0, ChunkID: 0 // Resend the first chunk
- M3: UUID: 0, ChunkID: 1

When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:
https://github.com/apache/pulsar/blob/44a055b8a55078bcf93f4904991598541aa6c1ee/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java#L1436-L1482

The bug can be easily reproduced using the testcase `testResendChunkMessages` introduced by this PR.

### Modifications

- When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.

(cherry picked from commit eb2e3a2)
RobertIndie added a commit that referenced this pull request Aug 31, 2023
)

### Motivation

Current, when the producer resend the chunked message like this:
- M1: UUID: 0, ChunkID: 0
- M2: UUID: 0, ChunkID: 0 // Resend the first chunk
- M3: UUID: 0, ChunkID: 1

When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:
https://github.com/apache/pulsar/blob/44a055b8a55078bcf93f4904991598541aa6c1ee/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java#L1436-L1482

The bug can be easily reproduced using the testcase `testResendChunkMessages` introduced by this PR.

### Modifications

- When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.

(cherry picked from commit eb2e3a2)
liangyepianzhou pushed a commit that referenced this pull request Sep 4, 2023
)

Current, when the producer resend the chunked message like this:
- M1: UUID: 0, ChunkID: 0
- M2: UUID: 0, ChunkID: 0 // Resend the first chunk
- M3: UUID: 0, ChunkID: 1

When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:
https://github.com/apache/pulsar/blob/44a055b8a55078bcf93f4904991598541aa6c1ee/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java#L1436-L1482

The bug can be easily reproduced using the testcase `testResendChunkMessages` introduced by this PR.

- When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.

(cherry picked from commit eb2e3a2)
liangyepianzhou pushed a commit that referenced this pull request Sep 4, 2023
)

Current, when the producer resend the chunked message like this:
- M1: UUID: 0, ChunkID: 0
- M2: UUID: 0, ChunkID: 0 // Resend the first chunk
- M3: UUID: 0, ChunkID: 1

When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:
https://github.com/apache/pulsar/blob/44a055b8a55078bcf93f4904991598541aa6c1ee/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java#L1436-L1482

The bug can be easily reproduced using the testcase `testResendChunkMessages` introduced by this PR.

- When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.

(cherry picked from commit eb2e3a2)
RobertIndie pushed a commit to apache/pulsar-client-go that referenced this pull request Mar 25, 2026
…ages (#1464)

Master Issue: #1446
related issue apache/pulsar#21070 and apache/pulsar#21101

### Motivation
Current, when the producer resend the chunked message like this:
```
M1: UUID: 0, ChunkID: 0
M2: UUID: 0, ChunkID: 0 // Resend the first chunk
M3: UUID: 0, ChunkID: 1
```
When the consumer received the M2, it will find that it's already tracking the UUID:0 chunked messages, and will then discard the message M1 and M2. This will lead to unable to consume the whole chunked message even though it's already persisted in the Pulsar topic.

Here is the code logic:
```Go
	if ctx == nil || ctx.chunkedMsgBuffer == nil || chunkID != ctx.lastChunkedMsgID+1 {
		lastChunkedMsgID := -1
		totalChunks := -1
		if ctx != nil {
			lastChunkedMsgID = int(ctx.lastChunkedMsgID)
			totalChunks = int(ctx.totalChunks)
			ctx.chunkedMsgBuffer.Clear()
		}
		pc.log.Warnf(fmt.Sprintf(
			"Received unexpected chunk messageId %s, last-chunk-id %d, chunkId = %d, total-chunks %d",
			msgID.String(), lastChunkedMsgID, chunkID, totalChunks))
		pc.chunkedMsgCtxMap.remove(uuid)
		pc.availablePermits.inc()
		return nil
	}
```
The bug can be easily reproduced using the testcase `TestChunkWithReconnection` and `TestResendChunkMessages` introduced by this PR.

### Modifications
The current chunk processing strategy is consistent with the behavior of the Java client:
https://github.com/apache/pulsar/blob/52a4d5ee84fad6af2736376a6fcdd1bc41e7c52f/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java#L1579

When receiving the new duplicated first chunk of a chunked message, the consumer discard the current chunked message context and create a new context to track the following messages. For the case mentioned in Motivation, the M1 will be released and the consumer will assemble M2 and M3 as the chunked message.
RobertIndie pushed a commit to apache/pulsar-client-cpp that referenced this pull request Jul 13, 2026
…-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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants