[ISSUE #10658] Fix PopConsumerService.revive aborting the whole batch on a single failed record - #10659
Conversation
… batch on a single failed record revive(PopConsumerRecord) chains getMessageAsync(record).thenCompose(...) with no exceptionally handler, and the batch revive(AtomicLong, int) awaits all per-record futures via CompletableFuture.allOf(...).join() with no surrounding try-catch. As a result a single failing record aborts the entire batch: writeRecords(failureList), deleteRecords(consumerRecords) and the currentTime advance are all skipped and revive() throws. If one record keeps failing (e.g. a persistently unreachable remote), revive gets stuck reprocessing the same batch forever, and the retries for the other healthy records in that batch are never persisted (head-of-line blocking). A record can fail in two ways: (1) the returned future completes exceptionally (e.g. reviveRetry throwing inside thenCompose, or a decode failure in the escape bridge), and (2) revive(record) throws synchronously before it returns a future (e.g. DefaultMessageStore.getMessageAsync is completedFuture(getMessage(...)) and getMessage throws). Handle both: add an exceptionally handler to revive(record) that logs and downgrades an async failure to a failed revive (returns false); and in the batch loop, catch a synchronous throw from revive(record) and turn it into completedFuture(false) instead of rethrowing and aborting the batch (the semaphore permit is released by the whenComplete stage, and acquire()'s InterruptedException is handled separately). Either way a single record's failure only affects that record, and writeRecords/deleteRecords/ currentTime still run. Add PopConsumerServiceTest#reviveShouldNotAbortBatchWhenGetMessageFailsExceptionally (async exceptional completion) and reviveShouldIsolateSynchronousReadFailureAndNotBlockHealthyRecords (synchronous throw plus head-of-line blocking); both fail before the fix and pass after.
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot
Summary
Fixes head-of-line blocking in PopConsumerService.revive(AtomicLong, int) where a single failing record would abort the entire batch via CompletableFuture.allOf(...).join(), preventing healthy records from being persisted and retried.
Findings
-
[Info]
PopConsumerService.java:564-569— The.exceptionally()handler correctly downgrades async read failures tofalse, routing the record through the existingfailureListbackoff-retry path. Clean approach that reuses existing retry infrastructure. -
[Info]
PopConsumerService.java:597-614— Good separation ofsemaphore.acquire()InterruptedExceptionhandling from therevive(record)sync-failure catch. Restoring the interrupt flag withThread.currentThread().interrupt()before wrapping inRuntimeExceptionis the correct pattern. -
[Info]
PopConsumerService.java:610-613— The sync-failure catch createsCompletableFuture.completedFuture(false)so the record enters thefailureListpath. The semaphore permit is correctly released by the downstreamwhenCompletestage sincecompletedFuturetriggers it immediately. -
[Info]
PopConsumerServiceTest.java— Two well-targeted tests covering both failure modes (async exceptionally and sync throw). Both verify that batch bookkeeping (writeRecords/deleteRecords/scanExpiredRecords) still runs after a failure, which is the key invariant this fix protects.
Assessment
This is a solid, minimal fix for a classic batch-processing head-of-line blocking problem. The error isolation is correct for both async and sync failure paths, semaphore management is sound, and the tests directly validate the fix. Scope is appropriately limited to the newer popkv path (popConsumerKVServiceEnable), not touching the legacy PopBufferMergeService.
Automated review by github-manager-bot
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #10659 +/- ##
=============================================
- Coverage 48.35% 48.24% -0.12%
+ Complexity 13510 13487 -23
=============================================
Files 1380 1380
Lines 101044 101097 +53
Branches 13094 13101 +7
=============================================
- Hits 48863 48772 -91
- Misses 46225 46346 +121
- Partials 5956 5979 +23 ☔ 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
Fixes PopConsumerService.revive so that a single failed record (async or sync) does not abort the entire revive batch. Adds an .exceptionally() handler to the per-record future chain and catches synchronous failures from revive(record) in the batch loop.
Findings
- [Critical]
PopConsumerService.java:564-568— The new.exceptionally()handler correctly catches async failures (e.g., remote read via escape bridge failing) and returnsfalseinstead of propagating the exception. This preventsallOf().join()from throwing and skipping thewriteRecords/deleteRecordscleanup. - [Warning]
PopConsumerService.java:597-608— The synchronous failure handling creates aCompletableFuture.completedFuture(false)as a fallback. This is correct, but note that thesemaphore.acquire()is now in a separate try-catch before the future creation. Ifrevive(record)throws synchronously, the semaphore permit was already acquired but the.whenCompletestage on the fallback future will release it. Verify that thethenAcceptcallback attached later (atfutureList.add(future.thenAccept(...))) also properly releases the semaphore in all paths. - [Info]
PopConsumerService.java:594-596— Good fix for theInterruptedExceptionhandling: properly restores the interrupt flag withThread.currentThread().interrupt()before throwing. - [Info] Tests are comprehensive:
reviveShouldNotAbortBatchWhenGetMessageFailsExceptionallyandreviveShouldIsolateSynchronousReadFailureAndNotBlockHealthyRecordsdirectly validate both failure modes.
Suggestions
Consider adding a brief comment on the fallback future explaining why it's needed (the synchronous throw path that bypasses the .exceptionally() chain).
Verdict
Solid fix for a critical reliability issue. The batch processing pattern is now resilient to both async and sync failures.
Automated review by github-manager-bot
|
[P1] Preserve failed records in the
The new exception-to- Please either attach the exception-to- |
…batch call site only Address the review on apache#10659: revive(PopConsumerRecord) is also used as the Consumer<PopConsumerRecord> callback of PopConsumerCache (enablePopBufferMerge), where the returned future is discarded, so a false result would never be consumed there. Converting exceptions to false inside revive(record) therefore created a contract that one caller silently ignores, and the "will retry" log would be misleading on that path. Move the .exceptionally handler out of revive(record) to the batch call site in revive(AtomicLong, int), where a false result is actually consumed and turned into a failureList backoff retry. revive(record) keeps its original exception semantics, so the PopConsumerCache path behaves exactly as it did before this PR. The pre-existing issue that an asynchronously failed revive can be cleared from the cache without being persisted for retry is tracked separately. Both regression tests still pass: reviveShouldNotAbortBatchWhenGetMessageFailsExceptionally and reviveShouldIsolateSynchronousReadFailureAndNotBlockHealthyRecords.
@fuyou001 Thanks for the careful review — you are right. Addressed by taking the first option in commit b84fcc0: the exception-to-
On the pre-existing cache-path behavior, I verified: an asynchronously failed revive was already lost before this PR — the exceptionally-completed future is unobserved, the revive-attempted records are not in Both regression tests still pass ( |
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot (re-review after new commit)
Summary
Re-reviewed after new commit (Jul 28). The previous suggestion to add explanatory comments on the fallback future path has been addressed — both the .exceptionally() call site and the synchronous catch block now have clear comments explaining why the conversion happens here and not inside revive(record) itself.
Changes Since Last Review
- Added inline comments explaining the
.exceptionally()placement rationale (call-site-specific semantics vs.revive(record)internal behavior) - Added inline comment on the synchronous fallback future explaining the semaphore release path
InterruptedExceptionhandling correctly restores the interrupt flag
Verdict
Previous findings remain addressed. The batch processing error isolation is solid. LGTM.
Automated review by github-manager-bot
|
[P1] Avoid leaving the revive service permanently interrupted The current The normal broker shutdown path calls while (true) {
try {
semaphore.acquire();
break;
} catch (InterruptedException e) {
// PopConsumerService shutdown uses the stopped flag and wakeup(), rather
// than thread interruption. Do not restore the interrupt flag here;
// otherwise subsequent acquire() and waitForRunning() calls may return
// immediately and leave the revive service in a permanent busy loop.
log.warn("Interrupted while acquiring semaphore, retry");
}
}Please also add a regression test that blocks on the semaphore, interrupts the revive thread once, releases the outstanding operation, and verifies that the remaining records are processed without repeated exceptions or CPU spinning. If the inherited |
|
[P1] Preserve the suspend flag in the backoff record When a synchronous or asynchronous revive failure is converted to This changes the semantics of records created by Please preserve the original value when constructing the retry record: PopConsumerRecord retryRecord = new PopConsumerRecord(
System.currentTimeMillis(),
record.getGroupId(),
record.getTopicId(),
record.getQueueId(),
record.getRetryFlag(),
nextInvisibleTime,
record.getOffset(),
record.getAttemptId(),
record.isSuspend());Please cover both synchronous and asynchronous failure paths with |
Which Issue(s) This PR Fixes
Fixes #10658
Brief Description
In the popkv pop consumer (
PopConsumerService), the batchrevive(AtomicLong, int)awaits all per-record futures withCompletableFuture.allOf(...).join()and has no per-record error isolation, so a single failing record aborts the whole batch (writeRecords(failureList),deleteRecords(consumerRecords)and thecurrentTimeadvance are skipped, andrevive()throws). A persistently failing record therefore blocks the whole batch forever, and the healthy records' retries in that batch are never persisted (head-of-line blocking).A record can fail two ways, both handled here:
revive(PopConsumerRecord)gets an.exceptionally(...)handler that logs and downgrades the failure to a "failed revive" (false), so it goes through the existingfailureListbackoff-retry path instead of propagating throughallOf(...).join().revive(record)(e.g.getMessageAsyncthrowing before it returns a future) is caught and turned intocompletedFuture(false)instead of being rethrown and aborting the batch. The semaphore permit is released by thewhenCompletestage;acquire()'sInterruptedExceptionis handled separately.Either way a single record's failure only affects that record, and
writeRecords/deleteRecords/currentTimestill run.Note: this is the newer popkv pop path (
PopConsumerService, gated bypopConsumerKVServiceEnable), which is disabled by default; it does not touch the legacyPopBufferMergeService.How Did You Test This Change?
Added two tests in
PopConsumerServiceTest, both failing before the fix and passing after:reviveShouldNotAbortBatchWhenGetMessageFailsExceptionally— a single record whose read completes exceptionally; assertsrevivedoes not throw and the record is consumed (batch bookkeeping still runs).reviveShouldIsolateSynchronousReadFailureAndNotBlockHealthyRecords— two records, one whose read throws synchronously and one healthy; assertsrevivereturns 2 and both original records are consumed (scanExpiredRecordsreturns 0), i.e. the failed record is isolated and the healthy record is not head-of-line blocked.Full
PopConsumerServiceTestpasses (19/19), andmvn -pl broker validatereports 0 checkstyle violations.