[fix][txn] End pending ack replay on read failures it does not classify - #26380
Draft
lhotari wants to merge 2 commits into
Draft
[fix][txn] End pending ack replay on read failures it does not classify#26380lhotari wants to merge 2 commits into
lhotari wants to merge 2 commits into
Conversation
Fixes apache#26368 ### Motivation `MLPendingAckStore.PendingAckReplay.run()` loops while `lastConfirmedEntry.compareTo(currentLoadPosition) > 0 && fillEntryQueueCallback.fillQueue()`, sleeping 1ms whenever the entry queue is empty. The two halves of that condition are measured from different positions: `lastConfirmedEntry` is a snapshot of the managed ledger's last confirmed entry taken in the constructor and is compared against `currentLoadPosition`, which starts at the cursor's mark-delete position, while whether anything can still be read is decided by `cursor.hasMoreEntries()`, which follows the cursor's read position. When those disagree permanently, `fillQueue()` issues no read but still returns `isReadable == true`, so the loop sleeps forever with no outstanding read and nothing logged. One way to reach it: the cursor persists a mark-delete position at the last entry of a ledger, that ledger is trimmed, and after a restart `ManagedCursorImpl.recoveredCursor` leaves the stored position unrepaired (it only substitutes when `entryId == -1`) while the read position moves to a later ledger. The replay executors are single threaded and assigned by hash, so a stuck replay also stops every other subscription sharing that thread from adding consumers: with transactions enabled every persistent subscription builds a `PendingAckHandle`, and `PersistentSubscription.addConsumerInternal` waits on `pendingAckHandleFuture()` with no timeout. The loop could not be stopped either. `cursor.isClosed()` was only checked before the loop, so a read whose completion never arrives could not be ended by closing the subscription, and the `InterruptedException` handler only logged, so the thread survived `ExecutorProvider.shutdownNow()`. `TopicTransactionBuffer` has the same recovery loop and was fixed for this in apache#13739 (commit 7dee63e), but the fix was never ported to `MLPendingAckStore`. ### Modifications - `FillEntryQueueCallback.fillQueue()`: when the cursor has no more entries and the queue is drained, set `isReadable = false` so the replay finishes. This mirrors the existing `TopicTransactionBuffer` implementation. Entries at or below the mark-delete position have already been applied, so completing here is correct. - `PendingAckReplay.run()`: re-check `cursor.isClosed()` while waiting for entries, so that closing the subscription ends a replay whose read completion never arrives. A cursor closed between two reads already ends the loop through the existing read failure handling, because that read fails synchronously. - `PendingAckReplay.run()`: on `InterruptedException`, restore the interrupt flag and end the replay through `replayFailed()` rather than continuing. An incomplete replay must not be reported as successful, and the task is now cancellable. - `PendingAckReplay.run()`: end every exit through a single `stopReplay()` step that marks the callback stopped and releases entries still queued. A read issued by `fillQueue()` can still be outstanding when the replay ends, and its completion runs on a managed ledger thread, so `readEntriesComplete` now releases entries directly instead of queueing them once the replay has stopped. The handover is done under a lock so the check and the enqueue cannot interleave, and entries are released outside it because releasing can run a deallocation callback. The queue keeps a single consumer: late callbacks release their own entries and never poll. This covers the normal `replayComplete()` exit too, which could already leak entries read past the point the replay needed. - `PendingAckHandleImpl.exceptionHandleFuture()`: do not retry once the handle is closing or closed. The retry path resets the state to `None` before scheduling `init()`, which defeats the `checkIfClose()` guard in `initPendingAckStore()` and reopens the pending ack store of a subscription that is going away. This was reachable before, and the new in-loop close check makes it reachable on the ordinary topic unload path. ### Verifying this change Three tests are added to `MLPendingAckStoreTest`. Each was verified to fail when the corresponding change is reverted. - `testReplayCompletesWhenCursorHasNoMoreEntries` builds the diverged state and asserts both that the replay completes and that a task queued behind it on the same executor still runs. - `testReplayStopsWhenCursorIsClosedWhileWaitingForEntries` drops a read completion, closes the cursor, and asserts the replay fails and releases the thread. - `testReplayStopsWhenInterrupted` drops a read completion, calls `shutdownNow()`, and asserts the thread terminates, that `replayFailed` is invoked, and that the replay is never reported as complete. - `testEntriesDeliveredAfterReplayEndedAreReleased` ends the replay with a read still in flight, then completes that read and asserts every delivered entry was released. ### Documentation - [x] `doc-not-needed` Assisted-by: Claude (Opus 5, Fable), OpenAI Codex (gpt-5.6-sol)
Fixes apache#26374 ### Motivation `MLPendingAckStore.FillEntryQueueCallback.readEntriesFailed` recognises three exception shapes and ends the replay for them. Everything else only logged an ERROR and decremented the outstanding read counter, so `fillQueue()` immediately re-issued the identical read. Two classes fall through, and both are reachable with the shipped defaults: - `NonRecoverableLedgerException` when `autoSkipNonRecoverableData` is false. A deleted or missing pending ack ledger arrives as `LedgerNotExistException`, and `OpReadEntry` only advances past a bad ledger when auto-skip is enabled, so the read position never moves and every retry is identical. Retrying can never succeed. - A plain `ManagedLedgerException`, such as BookKeeper's `BookieHandleNotAvailableException` (code -8). Retrying is right here, but not at this rate. The result is a hot loop of roughly a thousand reads per second, one ERROR line each, holding a transaction replay thread. Those executors are single threaded and assigned by hash, and `PersistentSubscription.addConsumerInternal` waits on `pendingAckHandleFuture()` with no timeout, so every other subscription on that thread stops being able to add consumers. The reporter of apache#26364 measured roughly 47.6k, 1.5k, 59.4k and 31.1k of these ERRORs across four brokers in fifteen minutes during a BookKeeper outage, and reproduced the deleted ledger case deterministically: an unrelated topic sharing the replay thread also stopped accepting subscriptions. This is the half of apache#12700 that was never finished. That PR set out to stop exactly this ("if any ledger was deleted from bookkeeper ... MLPendingAckStore will not stop recovering and continue to report the exception") and added both the guard and `TransactionTest.testEndTPRecoveringWhenManagerLedgerDisReadable`. But that test enables `autoSkipNonRecoverableData`, so the default configuration was left spinning. ### Modifications - `readEntriesFailed` now ends the replay for every failure. The three shapes it already recognised keep completing the replay, exactly as the test above pins; any other failure is recorded and the attempt is reported through `replayFailed`, letting `PendingAckHandleImpl.exceptionHandleFuture` decide what happens next. It already classifies correctly: transient failures reschedule `init()` with backoff, which frees the replay thread between attempts, and non-recoverable ones fail the subscription instead of retrying a read that cannot succeed. - `fillQueue()` now checks `isReadable` after `outstandingReadsRequests`. `readEntriesFailed` clears `isReadable` before its decrement, so a thread that observes the decrement must also observe the cleared flag, and no read can be issued after a failure. The reverse operand order would allow one more. - A failed attempt rewinds the cursor. Reads run ahead of processing, so entries that were read but never applied have already advanced the shared cursor's read position and are released when the attempt ends. The cursor is cached by the managed ledger, so without a rewind the next attempt would resume past those entries, skip them, and report the incomplete replay as successful. - A failed attempt closes its buffered writer. The store is abandoned afterwards, and with `transactionPendingAckBatchedWriteEnabled` the writer's flush task reschedules itself forever, with nothing left able to stop it. - The per-failure log is split. Failures that complete the replay log at WARN, because they never reach `exceptionHandleFuture` and the handle goes on to log its recovery as a success; a cursor closed between two reads fails synchronously with no log at any layer, so this was otherwise invisible. Failures reported downstream drop to DEBUG, where `exceptionHandleFuture` writes one WARN per paced attempt or an ERROR when it gives up. Note for operators: the old `"MLPendingAckStore of topic ... stat reply fail!"` line is gone. The equivalents are the WARN above and the messages from `exceptionHandleFuture`. ### Verifying this change Added to `MLPendingAckStoreTest`, each verified to fail when its change is reverted: - `testReadFailureEndsReplayAttempt`, over `LedgerNotExistException`, a plain `ManagedLedgerException` and `TooManyRequestsException`: the original exception reaches `exceptionHandleFuture` unwrapped, exactly one read is issued, the replay thread is released, the replay is never also reported complete, and the cursor is rewound. - `testReadFailuresThatCompleteTheReplay`, over the three recognised shapes: each still completes, none reaches `exceptionHandleFuture`, and the cursor is not rewound. This pins the discriminating shape; routing every failure to `replayFailed` fails it. - `testFailedReplayAttemptClosesBufferedWriter`: the flush timer is cancelled. `MLPendingAckStoreTest` (16) and `TransactionTest` (36) pass, including `testEndTPRecoveringWhenManagerLedgerDisReadable`. ### Documentation - [x] `doc-not-needed` Assisted-by: Claude (Opus 5, Fable), OpenAI Codex (gpt-5.6-sol)
lhotari
marked this pull request as draft
August 19, 2026 07:13
lhotari
requested review from
Technoboy-,
coderzc,
liangyepianzhou,
merlimat and
nodece
August 19, 2026 13:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #26374
Important
Based on #26369. That PR's commit appears in this diff until it merges — please review only the
top commit,
52f030b. This change composes with thestopReplay()machinery #26369 introduces and is not separable from it.Motivation
MLPendingAckStore.FillEntryQueueCallback.readEntriesFailedrecognises three exception shapes and endsthe replay for them. Everything else only logged an ERROR and decremented the outstanding read counter,
so
fillQueue()immediately re-issued the identical read.Two classes fall through, both reachable with the shipped defaults:
NonRecoverableLedgerExceptionwhenautoSkipNonRecoverableDatais false. A deleted or missingpending-ack ledger arrives as
LedgerNotExistException, andOpReadEntryonly advances past a badledger when auto-skip is enabled — so the read position never moves and every retry is byte-identical.
Retrying can never succeed.
ManagedLedgerException, e.g. BookKeeper'sBookieHandleNotAvailableException(code-8). Retrying is correct here, but not at this rate.The result is a hot loop of ~1000 reads/second, one ERROR line each, holding a transaction replay
thread. Those executors are single-threaded and assigned by identity hash, and
PersistentSubscription.addConsumerInternalwaits onpendingAckHandleFuture()with no timeout, soevery other subscription on that thread stops being able to add consumers.
The reporter of #26364 measured ~47.6k / 1.5k / 59.4k / 31.1k of these ERRORs across four brokers in 15
minutes during a BookKeeper outage, and reproduced the deleted-ledger case deterministically: an
unrelated topic sharing the replay thread also stopped accepting subscriptions.
This is the unfinished half of #12700. That PR set out to stop exactly this — "if any ledger was
deleted from bookkeeper ... MLPendingAckStore will not stop recovering and continue to report the
exception" — and added both the guard and
TransactionTest.testEndTPRecoveringWhenManagerLedgerDisReadable.But its test enables
autoSkipNonRecoverableData, so the default configuration was left spinning.Modifications
readEntriesFailedends the replay for every failure. The three shapes it already recognised keepcompleting the replay, exactly as the existing test pins. Any other failure is recorded and the attempt
is reported through
replayFailed, lettingPendingAckHandleImpl.exceptionHandleFuturedecide whathappens next — it already classifies correctly: transient failures reschedule
init()with backoff(freeing the replay thread between attempts), non-recoverable ones fail the subscription rather than
retrying a read that cannot succeed.
fillQueue()checksisReadableafteroutstandingReadsRequests.readEntriesFailedclearsisReadablebefore its decrement, so a thread observing the decrement must also observe the clearedflag — no read can be issued after a failure. The reverse operand order would permit one more.
applied have already advanced the shared cursor's read position and are released when the attempt
ends. The cursor is cached by the managed ledger, so without a rewind the next attempt would resume
past those entries, skip them, and report the incomplete replay as successful. (
cursor.rewind()setsthe read position to the first entry after the mark-delete position — exactly where the next attempt's
currentLoadPositionstarts. Re-reading already-applied entries is safe: the replay handlersdeduplicate.)
transactionPendingAckBatchedWriteEnabledits flush task reschedules itself forever with nothing leftable to stop it.
exceptionHandleFuture, and the handle goes on to log recovery as a success; a cursor closed betweentwo reads fails synchronously with no log at any layer, so this was otherwise invisible. Failures
reported downstream drop to DEBUG, where
exceptionHandleFuturewrites one WARN per paced attempt oran ERROR when it gives up.
Note
Operator-visible change. The
"MLPendingAckStore of topic ... stat reply fail!"line is gone — itwas the string operators grepped for. The equivalents are the new WARN and the messages from
exceptionHandleFuture. Separately, a permanently unreadable pending-ack log now failssubscribefast instead of hanging forever; that matches what the same exception already does at store-open time.
Remedy for a genuinely lost ledger: enable
autoSkipNonRecoverableDataand reload the topic.Verifying this change
Added to
MLPendingAckStoreTest, each verified to fail when its corresponding source change isreverted:
testReadFailureEndsReplayAttempt— overLedgerNotExistException, a plainManagedLedgerExceptionand
TooManyRequestsException: the original exception reachesexceptionHandleFutureunwrapped(
isRetryableExceptiondiscriminates on the concrete class), exactly one read is issued, the replaythread is released, the replay is never also reported complete, and the cursor is rewound.
testReadFailuresThatCompleteTheReplay— over the three recognised shapes: each still completes, nonereaches
exceptionHandleFuture, and the cursor is not rewound. This pins the discriminating shape:routing every failure to
replayFailedfails it (that undiscriminating form was tried and revertedwhile preparing [fix][txn] Stop the pending ack replay loop from spinning forever #26369).
testFailedReplayAttemptClosesBufferedWriter— the flush timer is cancelled.MLPendingAckStoreTest(16) andTransactionTest(36) pass locally, includingtestEndTPRecoveringWhenManagerLedgerDisReadable.Does this pull request potentially affect one of the following parts:
and is retried on a timer with backoff, instead of occupying the thread in a read loop.
Documentation
doc-not-neededFollow-ups deliberately left out
TopicTransactionBufferhas the identical guard and hole, plus a deadexceptionNumbercounter. Its only failure path closes the whole topic, so it needs its own design and tests rather
than a mechanical port of this classification.
exceptionHandleFuturegives up it replacespendingAckStoreFuturewith a failed future without closing the real store, so the pending-ack cursorand managed ledger stay open in the factory cache. Pre-existing, and riskier to fix than it looks — a
close racing the next
init()can hand the new attempt a mid-close managed ledger whose reads failFenced, which this code maps to complete. Issue to follow.Assisted-by: Claude (Opus 5, Fable), OpenAI Codex (gpt-5.6-sol)