[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 5 commits 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.
There was a problem hiding this comment.
Fixed in be1cecc. The retry reset now uses an atomic state update that preserves Close, replacing the separate close check and state = None assignment. If a retry was already queued when the handle closes, its None → Initializing CAS cannot change Close.
The error transition also preserves Close, preventing a late failure from changing it to Error and enabling a subsequent retry. Added testReplayFailurePreservesClosedHandle for repeated retryable failures after close; it fails with the old error transition (expected Close but found Error). This test covers the late-failure sequence; the atomic update addresses the concurrent ordering you described.
All 71 scoped pending-ack and transaction tests pass locally with retries disabled, along with quickCheck.
Use atomic state transitions so retry and error handling cannot overwrite Close. Cover repeated late replay failures with a regression test.
Keep the store future available to concurrent close after a late retryable failure. Release polled replay entries even when decoding fails or an empty batch is skipped. Add deterministic regressions for store closure and entry ownership. Assisted-by: OpenAI Codex
Publish the store future under the handle monitor before invoking the provider, and preserve it on terminal replay failures so close can await creation and cleanup. Assisted-by: OpenAI Codex
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 queued entries on normal and abnormal replay completion. Stopaccepting entries under the same lock used by read completion, so late callbacks release their own
entries without racing the queue drain. Entry deallocation runs outside the lock and the queue keeps
a single consumer.
PendingAckHandleImpl.exceptionHandleFuture(): atomically preserveClosewhen resetting the statefor retry, and preserve
Closeon the error path too. A separate close check is insufficient becausecloseAsync()changes the state outside the handle monitor. A late replay failure must not reopenthe pending ack store of a subscription that is going away.
Verifying this change
Five regression 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.testEntriesDeliveredAfterReplayEndedAreReleaseddelivers entries after replay has stopped andverifies that the callback releases them.
testReplayFailurePreservesClosedHandledelivers repeated retryable failures to a closed handle andverifies that it remains closed and cannot return to initialization.
MLPendingAckStoreTest, the rest oforg.apache.pulsar.broker.transaction.pendingack.*, andTransactionTestall pass locally after merging master and strengthening the close-state transitions.quickCheckpasses too. The final run used-PtestRetryCount=0; the new closed-handle regressionwas also verified to fail with the old error-state transition (
expected Close but found Error).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.
Compatibility with recent recovery fixes
Merged master through
6086ba81f3d. #26512 preserves cursor properties, #26474 restores batch ACKindexes, and #26509 preserves ledger properties. None changes pending-ack replay termination or the
missing-ledger repair condition in
ManagedCursorImpl.recoveredCursor, which still only repairspositions with
entryId == -1. #26335 fixes transaction-buffer recovery cancellation separately.Follow-ups deliberately left out of this PR
Tracked in #26368:
[fix][broker] Cancel queued transaction snapshot recovery on topic close #26335. That change does not cover
MLPendingAckStore; this pending-ack fix is still needed.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)