[fix][txn] Stop the pending ack replay loop from spinning forever - #26369
[fix][txn] Stop the pending ack replay loop from spinning forever#26369lhotari wants to merge 1 commit into
Conversation
|
Thanks for the fix. One small cleanup case worth handling: if replay exits while a read is still outstanding, a later |
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)
89dda02 to
5da5f22
Compare
|
Good catch — the race is real and I've fixed it. Pushed. I went a bit further than a bare flag, because on closer look the flag alone is not sufficient and the
So the handover is done under a small lock: Two things I added on top of your report:
Also covered by a new test, Separately, and not addressed here: the read-failure path itself still re-issues the same doomed read |
| // Never retry once the handle is closing or closed. The retry path below resets the state to | ||
| // None before scheduling init(), which would defeat the checkIfClose() guard in | ||
| // initPendingAckStore() and reopen the pending ack store of a subscription that is going away. | ||
| if (isRetryableException(t) && !checkIfClose()) { |
There was a problem hiding this comment.
Thanks for handling the close-during-replay case. There is still a small check-then-act window here:
- A retryable failure observes that the handle is not closed.
closeAsync()changes the state toClose.- The retry path resumes, changes the state to
None, and schedulesinit().
This ordering allows the pending-ack store to be initialized again after closing has started.
Fixes #26368
Reported by @kaminski-dev in #26364.
Motivation
MLPendingAckStore.PendingAckReplay.run()loops whilesleeping 1ms whenever the entry queue is empty. The two halves of that condition are measured from
different positions:
lastConfirmedEntryis afinalsnapshot of the managed ledger's last confirmed entry taken in theconstructor, compared against
currentLoadPosition, which is seeded from the cursor's mark-deleteposition;
cursor.hasMoreEntries(), which follows the cursor'sread position.
When those disagree permanently,
fillQueue()issues no read (itshasMoreEntries()gate is false) yetstill returns
isReadable == true, the queue stays empty, and the loop sleeps forever — with nooutstanding read and nothing logged. One way to reach that state: the cursor persists a mark-delete
position at the last entry of a ledger, that ledger becomes trimmable, and after a restart
ManagedCursorImpl.recoveredCursorleaves the stored position unrepaired (it only substitutes whenposition.getEntryId() == -1) while the read position moves to a later ledger.The replay executors are single threaded and assigned by identity 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, andPersistentSubscription.addConsumerInternalwaits on
pendingAckHandleFuture()with no timeout.The loop could not be stopped either.
cursor.isClosed()was checked only before the loop, so a readwhose completion never arrives could not be ended by closing the subscription, and the
InterruptedExceptionhandler only logged, so the thread survivedExecutorProvider.shutdownNow().TopicTransactionBufferhas the same recovery loop and received the corresponding fix in #13739(
7dee63ed707, 2022-01-18), whose commit message describes this exact state:It was never ported to
MLPendingAckStore. The escape hatch thatMLPendingAckStoreused to have wasremoved a month earlier by #12700 (
a962137f530), which movedcursor.isClosed()out of the loop body.Modifications
FillEntryQueueCallback.fillQueue(): when the cursor has no more entries and the queue is drained,set
isReadable = falseso the replay finishes. This mirrors the existingTopicTransactionBufferimplementation. Entries at or below the mark-delete position have already been applied, so completing
here is correct.
PendingAckReplay.run(): re-checkcursor.isClosed()while waiting for entries, so that closing thesubscription 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 in
ManagedCursorImpl#asyncReadEntriesWithSkip.PendingAckReplay.run(): onInterruptedException, restore the interrupt flag and end the replaythrough
replayFailed()rather than continuing. An incomplete replay must not be reported assuccessful, and the task is now cancellable.
PendingAckReplay.run(): release any entries left in the queue on every abnormal exit. A readissued by
fillQueue()can still be outstanding when the replay ends, so the queue may hold entrieswhose pooled buffers would otherwise never be released. This also covers the pre-existing
catch (Exception)exit.PendingAckHandleImpl.exceptionHandleFuture(): do not retry once the handle is closing or closed. Theretry path resets the state to
Nonebefore schedulinginit(), which defeats thecheckIfClose()guard in
initPendingAckStore()and reopens the pending ack store of a subscription that is goingaway. This was reachable before via the pre-loop close check; the new in-loop 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 correspondingsource change is reverted:
testReplayCompletesWhenCursorHasNoMoreEntriesbuilds the diverged state and asserts both that thereplay completes and that a task queued behind it on the same executor still runs — the latter is what
the stall actually broke.
testReplayStopsWhenCursorIsClosedWhileWaitingForEntriesdrops a read completion, closes the cursor,and asserts the replay fails and releases the thread.
testReplayStopsWhenInterrupteddrops a read completion, callsshutdownNow(), and asserts thethread terminates, that
replayFailedis invoked, and that the replay is never reported as complete.MLPendingAckStoreTest, the rest oforg.apache.pulsar.broker.transaction.pendingack.*, andTransactionTestall pass locally.Does this pull request potentially affect one of the following parts:
executor thread indefinitely, and it responds to interruption.
Documentation
doc-not-neededMatching PR in forked repository
PR in forked repository: not applicable — the change is small and covered by the added unit tests.
Follow-ups deliberately left out of this PR
Tracked in #26368:
TopicTransactionBufferhas the opposite gap: it has theelseescape but nocursor.isClosed()check anywhere and a bare
//no-opInterruptedExceptionhandler. Its abort path closes the topic,so it deserves its own change and test.
LedgerNotExistException(with the defaultautoSkipNonRecoverableData=false) leavesisReadable == trueand re-issues the same doomed read.Widening that guard needs care: every
isReadable = falseexit currently falls through toreplayComplete(), andTransactionTest.testEndTPRecoveringWhenManagerLedgerDisReadableexplicitlyasserts that a fenced managed ledger or a closed cursor leaves the handle
Ready. Changing that is adeliberate behaviour change and belongs in its own PR.
ManagedLedgerImpl.asyncOpenCursorreturns a cached, already-open cursor verbatim. If a secondMLPendingAckStoreis ever built over a cursor whose read position is already past the new store'slast-confirmed-entry snapshot (an unclean close followed by a reload on the same broker), the new
completion branch fires immediately and reports a replay that applied nothing. The trimmed-ledger
case this PR targets is genuinely unrecoverable so completing is right, but the branch cannot
distinguish the two; a
DEBUGline records all four positions when it fires. Distinguishing themproperly belongs with item 4.
ManagedCursorImpl.recoveredCursorto repair any persisted position whose ledgerno longer exists, not only positions with
entryId == -1, which would prevent the diverged statefrom forming at all.
Assisted-by: Claude (Opus 5, Fable), OpenAI Codex (gpt-5.6-sol)