[ISSUE #10263] Add consumer message deduplication with correct ackIndex semantics#10606
[ISSUE #10263] Add consumer message deduplication with correct ackIndex semantics#10606Aias00 wants to merge 8 commits into
Conversation
- Add MessageDeduplicator class to manage deduplication cache - Use ConcurrentHashMap for thread-safe storage - Support time-based expiration - Prefer user-defined keys, fallback to msgId - Add deduplication configurations to consumers - enableMessageDeduplication: enable/disable feature - deduplicationCacheSize: max cache size [1000, 100000] - deduplicationCacheExpireTime: expire time [10000, 3600000]ms - Integrate deduplication in DefaultMQPushConsumerImpl - Initialize MessageDeduplicator in start() - Add configuration validation in checkConfig() - Cleanup in shutdown() - Integrate deduplication in ConsumeMessageConcurrentlyService - Add filterDuplicateMessages() method - Filter duplicates before calling message listener - Handle all-duplicate and partial-duplicate scenarios - Update DefaultLitePullConsumer with deduplication configs This implementation addresses at-least-once delivery duplicates while maintaining backward compatibility (disabled by default). Co-Authored-By: Claude <noreply@anthropic.com>
HIGH fixes: - Fix duplicate marking before successful consumption - Only mark messages as processed after successful consumption - Added markMessagesAsProcessed() method called after success - Fix offset advancement issue with filtered duplicates - Create new filtered list instead of mutating original - Use original list for ProcessQueue offset management - Duplicates included in offset advancement - Remove no-op LitePull deduplication API - Removed unimplemented configuration from DefaultLitePullConsumer - Users should use PushConsumer for deduplication support Changes: - filterDuplicateMessages() now returns new list, doesn't modify input - Added markMessagesAsProcessed() for post-consumption marking - Deduplication only marks successfully consumed messages - Original message list used for offset/ACK processing - Removed LitePull dedup config fields and getters/setters Known limitations: - Only supports push concurrent consumption mode - Orderly and POP modes not yet implemented - Unit tests pending Co-Authored-By: Claude <noreply@anthropic.com>
MEDIUM fix: - Fix ackIndex mapping issue with filtered duplicates - When duplicates exist, ackIndex is adjusted to cover all original messages - On success: ackIndex = msgs.size() - 1 (all messages considered successful) - On failure: all messages retry (duplicates will be detected again) - This prevents incorrect ACK/retry behavior Documentation: - Added JavaDoc noting deduplication only supports concurrent mode - Added warning log when deduplication enabled with orderly listener - Clarified supported modes in startup log Tests added (10 test cases): - Basic duplicate detection - Message key extraction (keys vs msgId) - Cache expiration behavior - No marking on consumption failure - All duplicates offset advancement - Partial duplicates with success - Concurrent access thread safety - Cache size limit enforcement - AckIndex adjustment with duplicates - Null key handling Test results: All 10 tests passing Co-Authored-By: Claude <noreply@anthropic.com>
…duplicates - Only mark messages up to listener's ackIndex in filteredMsgs, not all - Map filtered-list ackIndex to original msgs position for processConsumeResult - Rename test to testFilteringPreservesOrder, clarify comments - Add tests for partial/full success ackIndex mapping scenarios This fixes message loss when listener returns CONSUME_SUCCESS with ackIndex < filteredMsgs.size() while duplicates exist. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an optional consumer-side message deduplication capability for concurrent consumption, aiming to reduce duplicate processing while preserving correct ackIndex semantics when duplicates are filtered out before listener invocation.
Changes:
- Add a
MessageDeduplicatorcache and lifecycle wiring inDefaultMQPushConsumerImpl. - Filter duplicate messages in
ConsumeMessageConcurrentlyServiceand mapackIndexfrom the filtered list back to the original batch. - Expose consumer configuration knobs for enabling deduplication and tuning cache size/expiry, plus unit tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| client/src/main/java/org/apache/rocketmq/client/impl/consumer/MessageDeduplicator.java | Adds a time-expiring deduplication key cache with a scheduled cleanup task. |
| client/src/main/java/org/apache/rocketmq/client/impl/consumer/ConsumeMessageConcurrentlyService.java | Filters duplicates before listener invocation and attempts to map/apply ackIndex correctly. |
| client/src/main/java/org/apache/rocketmq/client/impl/consumer/DefaultMQPushConsumerImpl.java | Initializes/shuts down the deduplicator and validates dedup-related config. |
| client/src/main/java/org/apache/rocketmq/client/consumer/DefaultMQPushConsumer.java | Adds enablement + cache tuning configuration fields and accessors. |
| client/src/test/java/org/apache/rocketmq/client/impl/consumer/MessageDeduplicationTest.java | Adds unit tests for dedup key extraction, expiration, and ackIndex-related scenarios. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Initialize message deduplicator if enabled | ||
| if (this.defaultMQPushConsumer.isEnableMessageDeduplication()) { | ||
| boolean isConcurrentMode = this.getMessageListenerInner() instanceof MessageListenerConcurrently; | ||
| if (!isConcurrentMode) { | ||
| log.warn("Message deduplication is only supported for concurrent consumption mode. " + | ||
| "Current listener type: {}. Deduplication will be disabled for orderly and POP consumption.", | ||
| this.getMessageListenerInner().getClass().getSimpleName()); | ||
| } | ||
| this.messageDeduplicator = new MessageDeduplicator( | ||
| this.defaultMQPushConsumer.getDeduplicationCacheSize(), | ||
| this.defaultMQPushConsumer.getDeduplicationCacheExpireTime()); | ||
| log.info("Message deduplication enabled for consumer group {} with cacheSize={}, expireTimeMs={}. " + | ||
| "Supported mode: concurrent. Not supported: orderly, POP.", | ||
| this.defaultMQPushConsumer.getConsumerGroup(), | ||
| this.defaultMQPushConsumer.getDeduplicationCacheSize(), | ||
| this.defaultMQPushConsumer.getDeduplicationCacheExpireTime()); | ||
| } |
| // Map filtered-list ackIndex to original msgs position | ||
| if (hasDuplicates && !filteredMsgs.isEmpty() && filteredAckIndex >= 0) { | ||
| // Find the position in original msgs that corresponds to filteredMsgs[filteredAckIndex] | ||
| MessageExt lastAckedMsg = filteredMsgs.get(filteredAckIndex); | ||
| int originalAckIndex = -1; | ||
| for (int i = 0; i < msgs.size(); i++) { | ||
| if (msgs.get(i) == lastAckedMsg) { | ||
| originalAckIndex = i; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (originalAckIndex >= 0) { | ||
| context.setAckIndex(originalAckIndex); | ||
| log.debug("Duplicate messages filtered, mapped filteredAckIndex {} to originalAckIndex {}", | ||
| filteredAckIndex, originalAckIndex); | ||
| } | ||
| } | ||
| // If no duplicates, ackIndex already refers to original msgs, no mapping needed | ||
| } |
| // Only mark successfully consumed messages (up to filteredAckIndex) | ||
| for (int i = 0; i <= filteredAckIndex; i++) { | ||
| MessageExt msg = filteredMsgs.get(i); | ||
| String dedupKey = MessageDeduplicator.getDeduplicationKey(msg); | ||
| if (dedupKey != null) { | ||
| MessageDeduplicator deduplicator = ConsumeMessageConcurrentlyService.this.defaultMQPushConsumerImpl.getMessageDeduplicator(); | ||
| if (deduplicator != null) { | ||
| deduplicator.markProcessed(dedupKey); | ||
| } | ||
| } | ||
| } | ||
| } |
| // Check if entry has expired | ||
| long currentTime = System.currentTimeMillis(); | ||
| if (currentTime - timestamp > expireTimeMs) { | ||
| // Entry expired, remove it and treat as non-duplicate | ||
| processedMessages.remove(messageKey); | ||
| return false; | ||
| } |
| int expectedMappedAckIndex = 1; // Position of newKey1 in original msgs | ||
| assertEquals("Mapped ackIndex should be position of newKey1 in original list", | ||
| 1, expectedMappedAckIndex); | ||
|
|
||
| // After retry, newKey2 would be processed and then marked | ||
| deduplicator.markProcessed(newKey2); | ||
| assertTrue("After retry success, newKey2 should be marked", deduplicator.isDuplicate(newKey2)); | ||
| } |
| // ackIndex mapping for full success: should be msgs.size() - 1 = 2 | ||
| // (all original messages considered successful for offset advancement) | ||
| int expectedMappedAckIndex = 2; // msgs.size() - 1 | ||
| assertEquals("For full success, mapped ackIndex should cover all original messages", | ||
| 2, expectedMappedAckIndex); | ||
| } |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #10606 +/- ##
=============================================
- Coverage 48.27% 48.20% -0.08%
- Complexity 13436 13455 +19
=============================================
Files 1378 1379 +1
Lines 100820 100987 +167
Branches 13041 13071 +30
=============================================
+ Hits 48670 48678 +8
- Misses 46208 46334 +126
- Partials 5942 5975 +33 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot
Summary
Adds consumer-side message deduplication for concurrent consumption mode, using a time-based cache to detect and skip duplicate messages before listener invocation. Includes correct ackIndex handling for partial success scenarios.
Findings
-
[Critical]
MessageDeduplicator.java— Missing lifecycle management forcleanupExecutor: TheScheduledExecutorServiceis created in the constructor but there is noshutdown()method or integration with consumer shutdown lifecycle. This will cause a thread leak when the consumer is stopped — the cleanup thread will prevent JVM shutdown and waste resources. Consider adding ashutdown()method and calling it fromConsumeMessageConcurrentlyService.shutdown()orDefaultMQPushConsumerImpl.shutdown(). -
[Warning]
MessageDeduplicator.java— PR description says "using Caffeine" but implementation usesConcurrentHashMap: The PR body states "Lightweight deduplication cache using Caffeine" but the actual implementation uses a plainConcurrentHashMapwith manual scheduled cleanup. Either update the description to match the implementation, or consider using Caffeine which provides built-in expiration (expireAfterWrite) and size-based eviction (maximumSize), eliminating the need for the manual cleanup executor entirely. -
[Warning]
ConsumeMessageConcurrentlyService.java:455— Inconsistent message list in hook context: When all messages are duplicates, the code passes the originalmsgstoconsumeMessageContext.setMsgList()(viafilteredMsgs.isEmpty() ? msgs : filteredMsgs) but uses an empty list for actual consumption. Hook implementations that inspectmsgListwill see the original list even though no messages were actually consumed. Consider passing the filtered list consistently, or documenting this behavior. -
[Warning]
MessageDeduplicator.java:removeOldestEntries()— O(n log n) cleanup cost: When the cache exceedsmaxCacheSize, the cleanup sorts all entries by timestamp to remove the oldest 25%. For a cache of 100,000 entries, this creates and sorts a large stream on every eviction. Consider using a more efficient eviction strategy (e.g., aConcurrentLinkedDequefor access ordering, or Caffeine's built-in eviction). -
[Info]
MessageDeduplicator.java— Thread factory naming: The cleanup thread uses a fixed prefix"DedupCleanup_"without the consumer group identifier. In environments with multiple consumers, this makes it hard to identify which consumer's cleanup thread is running in thread dumps. Consider passing the consumer group to the constructor and including it in the thread name. -
[Info]
DefaultMQPushConsumer.java— No setter validation:setDeduplicationCacheSize()andsetDeduplicationCacheExpireTime()accept any value without range checks. Validation only occurs incheckConfig()at consumer start. If values are changed after start (e.g., via JMX or dynamic config), invalid values could cause unexpected behavior. Consider adding range validation in the setters or documenting that changes after start are not supported. -
[Info]
ConsumeMessageConcurrentlyService.java:filterDuplicateMessages()—isDuplicaterace condition: TheisDuplicate()method checks if a key exists and then removes it, but this check-then-remove is not atomic. Between the check and removal, another thread could have already removed the entry. While this is benign (worst case: the same message is processed twice, which deduplication is meant to prevent but cannot guarantee in all race conditions), it's worth documenting this limitation.
Suggestions
- Add a
shutdown()method toMessageDeduplicatorand wire it into the consumer shutdown path — this is the most important fix. - Consider replacing the manual
ConcurrentHashMap+ cleanup executor with Caffeine'sCache<String, Boolean>withexpireAfterWrite()andmaximumSize()for cleaner, more efficient eviction. - Add an integration test that verifies deduplication works correctly across consumer restarts (cache should be empty after restart, so messages should not be deduplicated).
- The feature is documented as concurrent-only. Consider adding a runtime warning in
ConsumeMessageOrderlyServiceandConsumeMessagePopConcurrentlyServicewhen deduplication is enabled but not supported.
Cross-repo Note
This is a client-side feature. No changes needed in apache/rocketmq broker/proxy. However, consider documenting that this is a best-effort, in-memory deduplication — it does not provide exactly-once semantics across consumer restarts or in multi-instance deployments.
Automated review by github-manager-bot
…tion - Fix ackIndex mapping for full success: when all filtered messages succeed, set ackIndex to cover all original messages (including skipped duplicates) - Fix isDuplicate race condition: use remove(key, expectedValue) to prevent race with concurrent markProcessed() calls - Only create MessageDeduplicator for concurrent consumption mode - Reuse markMessagesAsProcessed helper instead of duplicating logic - Add testable mapAckIndex() static method for verification - Add comprehensive tests for ackIndex mapping scenarios: - Partial success with leading/middle/trailing duplicates - Full success edge case - No duplicates scenario Co-Authored-By: Claude <noreply@anthropic.com>
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot (Re-review)
Summary
Author addressed previous review feedback in commit 14d31189. Key improvements: lifecycle management added, race condition fixed, initialization guard for non-concurrent modes, and ackIndex mapping refactored into a testable static method with comprehensive tests.
Changes Since Last Review
✅ [Critical] Lifecycle management — FIXED: MessageDeduplicator.shutdown() properly shuts down the cleanup executor with timeout and interrupt handling. Called from ConsumeMessageConcurrentlyService.shutdown() in the consumer lifecycle. Thread leak concern resolved.
✅ [Warning] Race condition in recordProcessed() — FIXED: Changed from unconditional remove() + put() to conditional processedMessages.remove(messageKey, timestamp). This correctly prevents the ABA problem where a concurrent cleanup could remove an entry that was just re-added by another thread.
✅ [Warning] Deduplicator created for all consumption modes — FIXED: DefaultMQPushConsumerImpl.start() now only initializes the deduplicator when enableMessageDeduplication && consumeMode == CONCURRENTLY. Orderly and POP modes no longer create unnecessary resources.
✅ ackIndex mapping — IMPROVED: Extracted mapAckIndex() as a package-private static method, making it independently testable. Added proper handling for the "all filtered messages succeed" case (returns originalMsgs.size() - 1 to cover trailing duplicates). New tests cover trailing duplicates, middle duplicates, no duplicates, and out-of-range ackIndex scenarios.
✅ Code readability — IMPROVED: Extracted markMessagesAsProcessed() method, separating the marking logic from the consumption callback. Much cleaner than the previous inline implementation.
Remaining Items (Non-blocking)
-
[Warning]
removeOldestEntries()still uses O(n log n) sort-based eviction. WithmaxCacheSize=100000, this creates a large sorted stream on every eviction. Consider aLinkedHashMapwith access-order for O(1) eviction, or simply removing a fixed number of entries by iterating the entrySet iterator (which forConcurrentHashMapis weakly consistent but sufficient for cache eviction). -
[Info] Thread name
"DedupCleanup_"still lacks consumer group context. Minor, but helpful for thread dump analysis in multi-consumer deployments. -
[Info] PR description mentions "Caffeine" but implementation uses
ConcurrentHashMap. Consider updating the description for accuracy.
Verdict
The critical issues from the previous review have been properly addressed. The code is well-structured with good test coverage. LGTM — ready for committer review.
Automated re-review by github-manager-bot
…lse duplicates filterDuplicateMessages only checked the global cache, which is populated after successful consumption, so two identical messages in one batch both reached the listener. Add a batch-local seen set so intra-batch duplicates are collapsed before the listener. The set is method-local, so it never pollutes cross-batch state and is discarded on failure (no marking, full retry). getDeduplicationKey returned raw keys/msgId, so equal business keys on different topics collided for a consumer subscribing to multiple topics. Scope the key as topic#key. Group isolation is already structural (per- instance deduplicator) so the group is not encoded. Co-Authored-By: Claude <noreply@anthropic.com>
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot (Re-review #2)
Summary
New commit 46dd21cf addresses two real bugs found after the previous LGTM:
- Batch-internal dedup leak — identical messages within the same batch were not caught because the global cache is only populated after successful consumption.
- Cross-topic false duplicates — the same business key on different topics would collide in the dedup cache.
Changes Since Last Review
✅ Batch-internal dedup — FIXED: Added a seenInBatch HashSet scoped to each filterDuplicateMessages() call. Combined with the global cache check via short-circuit ||, this correctly collapses intra-batch duplicates. The method was refactored to a static package-private overload for testability — clean approach.
✅ Cross-topic key scoping — FIXED: getDeduplicationKey() now returns topic + "#" + rawKey. The # separator is safe per TopicValidator rules (topic names only allow ^[%|a-zA-Z0-9_-]+$). Group-level isolation is correctly provided structurally by the per-consumer-instance deduplicator.
✅ Tests — COMPREHENSIVE: New tests cover intra-batch dedup, mixed global+batch dedup, topic-scoped keys, null deduplicator passthrough, and the no-topic defensive fallback. Well-structured.
Remaining Items (Non-blocking)
-
[Info]
removeOldestEntries()still uses O(n log n) sort-based eviction. Not a regression from this commit, but worth tracking for future optimization (e.g.,LinkedHashMapaccess-order or fixed-count eviction). -
[Info] Thread name
"DedupCleanup_"still lacks consumer group context. Minor.
Verdict
Both bugs are properly fixed with clean implementation and good test coverage. LGTM — ready for committer review.
Automated re-review by github-manager-bot
…licates, mark after drop check Address PR review feedback on two correctness bugs in the ack/dedup interaction: 1. processConsumeResult ran its send-back loop over the original msgs list while ackIndex was relative to the deduped list the listener consumed. In [A, dup(A), B] with the listener acking only A, the index was remapped onto the original list and dup(A) got sent back for retry even though it was a skipped duplicate, not a failure. Fix: carry the consumed list as processedMsgs on ConsumeRequest (defaults to the original batch when dedup is off). processConsumeResult iterates processedMsgs for send-back and consume stats, so skipped duplicates are neither retried nor counted as failures. removeMessage keeps receiving the full original batch since it drains by queueOffset and must keep msgTreeMap/offset consistent. The buggy mapAckIndex helper and its tests are removed — the remapping was the concept being fixed. 2. markMessagesAsProcessed ran in ConsumeRequest.run() before the processQueue.isDropped() gate that calls processConsumeResult. If the queue was dropped between consumption and result processing, uncommitted messages already entered the dedup cache and would suppress their own redelivery (at-least-once violation). Fix: move dedup-cache marking into processConsumeResult, guarded by !processQueue.isDropped(), so only committed results are marked. Tests: add testDupSkippedNotSentBack (drives ConsumeRequest.run() end-to-end; asserts only B is sent back and only A is marked) and testDroppedProcessQueueDoesNotPoisonCache (dropped queue does not populate the cache). Remove the 5 mapAckIndex-based tests. Co-Authored-By: Claude <noreply@anthropic.com>
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Remaining Items
-
[Warning]
filteredMsgs.isEmpty()fallback inConsumeRequest.run()may cause duplicate retries:this.processedMsgs = filteredMsgs.isEmpty() ? msgs : filteredMsgs;
When all messages in a batch are duplicates,
filteredMsgsis empty and the code falls back tomsgs(the original batch). The listener is then invoked with messages that were all identified as duplicates. If the listener returnsRECONSUME_LATER, all those "duplicate" messages are sent back for retry — defeating the purpose of deduplication.Suggested fix: When
filteredMsgs.isEmpty(), skip the listener invocation and treat the batch asCONSUME_SUCCESS(all were duplicates, nothing to do):List<MessageExt> filteredMsgs = filterDuplicateMessages(msgs); if (filteredMsgs.isEmpty()) { // All messages are duplicates — nothing to consume. // Mark offset and return without invoking the listener. long offset = consumeRequest.getProcessQueue().removeMessage(msgs); // ... update offset ... return; } this.processedMsgs = filteredMsgs;
This also avoids the unnecessary re-marking of duplicate entries in the dedup cache (which extends their TTL without benefit).
-
[Info] Minor: the
processedMsgsfield defaults tomsgsin the constructor and is reassigned inrun(). Consider initializing it tonulland asserting non-null inprocessConsumeResult()to catch any future code path that forgets to set it. Non-blocking.
Verdict
The processedMsgs approach is a clear improvement in clarity and correctness. The filteredMsgs.isEmpty() fallback is the only remaining edge case worth addressing. LGTM with the above suggestion — ready for committer review once the empty-filter case is handled.
Automated re-review by github-manager-bot
…dup cache Address the last review remaining items on PR apache#10606: * Warning item: when every message in a batch is a duplicate, filteredMsgs is empty. The old code set processedMsgs = msgs on that path, so processConsumeResult re-ran markMessagesAsProcessed on the full original batch, re-stamping every duplicate entry's timestamp and needlessly extending its TTL. Now processedMsgs is always the deduped list (empty when all are duplicates): processConsumeResult clamps ackIndex to -1, sends nothing back (skipped duplicates are never retried) and skips dedup-cache marking. Offset still advances via originalMsgs. The hook context msgList also now reflects what the listener actually consumed instead of the original batch. * Info item: processedMsgs defaults to null instead of msgs, and processConsumeResult recovers (falling back to the original batch, with a warning) if some future path forgets the assignment, instead of NPE'ing. Add MessageDeduplicator.getProcessedTimestamp for test visibility and a regression test that drives ConsumeRequest.run() end-to-end on an all-duplicate batch: asserts the listener is not invoked, nothing is sent back, the ProcessQueue is drained, and cache timestamps are unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot (Re-review #3)
Summary
New commit d9f96676 ("Handle all-duplicate batch without re-marking dedup cache") correctly addresses the remaining warning items from the previous review. The all-duplicate batch edge case is now handled cleanly.
Changes Since Last Review
✅ [Warning] filteredMsgs.isEmpty() fallback — FIXED: When all messages in a batch are duplicates, processedMsgs is now an empty list. The listener is not invoked (returns CONSUME_SUCCESS directly), no messages are sent back for retry, and dedup cache entries are not re-stamped (TTL preserved). This eliminates the previous issue where the listener would be called with duplicate messages and cache entries would have their TTL silently extended.
✅ [Info] processedMsgs field initialization — IMPROVED: Changed from defaulting to msgs in the constructor to intentionally null until run() sets it. processConsumeResult now has an explicit null check with a warning log. This makes programming errors visible rather than silently using wrong data — good defensive design.
✅ Hook context correctness — FIXED: consumeMessageContext.setMsgList(processedMsgs) now passes the filtered list, so hook implementations see exactly what the listener consumes.
✅ Test coverage — COMPREHENSIVE: New test testAllDuplicatesSkipsListenerAndDoesNotRefreshTtl verifies:
- Listener not invoked when all messages are duplicates
- No messages sent back for retry
- ProcessQueue properly drained (offset advances)
- Cache timestamps not refreshed (TTL not extended)
Minor Observations (Non-blocking)
-
[Info]
msgsToConsumevariable:processedMsgs.isEmpty() ? Collections.emptyList() : processedMsgsis functionally equivalent to justprocessedMsgs(since an empty list is already empty). The explicit intent comment makes the purpose clear, so this is fine as a readability choice. -
[Info]
getProcessedTimestamp(): Package-private test-only accessor is acceptable. Consider adding a@VisibleForTestingannotation if the project uses Guava, for clarity.
Verdict
All previously identified issues have been addressed. The deduplication feature is now well-structured with correct edge case handling, proper lifecycle management, and comprehensive test coverage. LGTM.
Automated review by github-manager-bot
What problem does this PR solve?
Issue: #10263
This PR adds message deduplication capability for consumers to prevent duplicate message processing, with correct handling of
ackIndexsemantics when duplicates are filtered.Problem Summary
When messages are redelivered due to network issues, rebalancing, or broker restarts, consumers may process the same message multiple times, causing:
Key Changes
MessageDeduplicator: Lightweight deduplication cache using ConcurrentHashMap with scheduled cleanup
ConsumeMessageConcurrentlyService: Filter duplicates before listener invocation
ackIndexsemantics for partial success:ackIndexin filteredMsgsackIndexto original msgs positionConfiguration:
deduplicationEnabled: Enable/disable feature (default: false)deduplicationCacheSize: Max cache entries (default: 10000)deduplicationExpireTime: Entry expiration in ms (default: 60000)Critical Fix for ackIndex Semantics
When duplicates exist and listener returns partial success:
Example:
Tests
MessageDeduplicationTest: 13 tests covering:ConsumeMessageConcurrentlyServiceTest: Existing tests passHow to use
Documentation
DefaultMQPushConsumer.mdwith deduplication configurationVerification
🤖 Generated with Claude Code