[fix][broker] Prevent stale read completions from stranding Failover subscriptions#26174
[fix][broker] Prevent stale read completions from stranding Failover subscriptions#26174alexandrebrg wants to merge 1 commit into
Conversation
…subscriptions On a single-active-consumer (Failover/Exclusive) subscription, internalRedeliverUnacknowledgedMessages clears havePendingRead and re-arms a fresh tail-wait read without cancelling a read that is already in flight (an in-flight read cannot be cancelled). When the disowned read later completes, readEntriesComplete/readEntriesFailed unconditionally clear havePendingRead again while the fresh op stays armed on the cursor: the dispatcher now believes no read is pending while the cursor holds an armed waiting read. The desync is benign while the cursor remains in waitingCursors, but on the next last-consumer disconnect cancelPendingRead() short-circuits on the false flag (the armed op survives) while PersistentSubscription#removeConsumer removes the cursor from waitingCursors. From then on the subscription is permanently stuck: every re-arm CAS-fails on the leftover op (ConcurrentWaitCallbackException is returned without rescheduling) and every publish's notifyCursors() misses the cursor. Only a topic unload recovers it. The cursor side is already generation-guarded ((op, opReadId)); the dispatcher's boolean mirror is not — that asymmetry is the defect. Fixes apache#26164 Signed-off-by: Alexandre Burgoni <alexandre.burgoni@clever.cloud>
f12a486 to
8f682f0
Compare
| * Tail for the reproduction schedule: disconnect the last consumer, publish once more, and assert the | ||
| * five-leg failure at quiescence. FAILS deterministically on buggy master. | ||
| */ | ||
| private void assertCursorStrandedAfterDisconnectAndWake(StuckState seed) throws Exception { |
There was a problem hiding this comment.
Could we make the final assertion behavioral by reconnecting a consumer and verifying that the newly published entry reaches sendMessages (or that the cursor advances)?
assertFalse(afterWake.stranded()) only rejects one exact internal tuple. It could pass for a different broken state, and at this point there is no active consumer, so the publish is not expected to dispatch anything even in the healthy case.
A reconnect + successful delivery assertion would match the externally observable failure reported in #26164 and make this regression test less coupled to the selected implementation.
| /** | ||
| * The last-consumer disconnect conversion for a durable cursor: the two operative production steps that | ||
| * {@code PersistentSubscription.removeConsumer} performs. {@code dispatcher.removeConsumer} calls | ||
| * {@code cancelPendingRead()} (which short-circuits when {@code havePendingRead} is already false, | ||
| * leaving the op armed), then the subscription strips the cursor from the waiting queue — see the | ||
| * comment at PersistentSubscription:381-388 explaining why the pending read is intentionally not | ||
| * cancelled for a durable cursor. | ||
| */ | ||
| private void applyLastConsumerDisconnect() throws Exception { | ||
| dispatcher.removeConsumer(consumer); | ||
| ledger.removeWaitingCursor(cursor); | ||
| } |
There was a problem hiding this comment.
Could this exercise subscription.removeConsumer(consumer, false) replace manually replaying the two selected lifecycle steps?
The failure depends on the ordering around dispatcher removal, cursor deactivation, and removeWaitingCursor. Calling dispatcher.removeConsumer followed directly by ledger.removeWaitingCursor bypasses deactivateCursor() and makes the regression test easier to drift from the production lifecycle.
If ConsumerStats mocking is the blocker, a small test seam would still be preferable to duplicating the lifecycle sequence here.
Motivation
On a single-active-consumer (Failover / Exclusive) subscription, the dispatcher tracks its one
outstanding read with a plain
volatile boolean havePendingRead, with no notion of which readthe flag refers to — while the cursor state it mirrors is generation-guarded
(
(op, opReadId)inManagedCursorImpl).internalRedeliverUnacknowledgedMessagesclearshavePendingRead, rewinds, and re-arms a freshtail-wait read without cancelling a read that is already in flight
(
cursor.cancelPendingReadRequest()can only cancel a waiting op). When the disowned readlater completes,
readEntriesComplete/readEntriesFailedrunhavePendingRead = falseastheir first statement — clearing a flag that now describes the newer armed read. Result: the
cursor holds an armed
waitingReadOpwhile the dispatcher believes no read is pending.The desync is benign while the cursor remains in
waitingCursors. It becomes permanent on thenext last-consumer disconnect:
cancelPendingRead()short-circuits on the false flag(
if (havePendingRead && ...)) so the armed op survives, andPersistentSubscription#removeConsumerthen removes the cursor fromwaitingCursors. Fromthere the subscription is stuck forever:
ConcurrentWaitCallbackException), whichreadEntriesFailedreturns on without rescheduling;addWaitingCursoris only reachable after a successful arm, so the cursor can never re-enterwaitingCursors;notifyCursors()polls a queue the cursor is not in.The consumer stays connected with permits, the backlog grows,
msgOutCounterstays 0, and onlya topic unload recovers — exactly the production signature reported in #
(
waitingReadOp=true,pendingReadOps=0,waitingCursorsCount=0, cursorstate=Open). OnFailover, every redeliver form (ack-timeout, negative ack, explicit redeliver, reconnect epoch
bump) funnels into
internalRedeliverUnacknowledgedMessages, so any ofread completion can mint the desync.
Modifications
readGeneration(plainlong, guarded by the dispatcher monitor) toPersistentDispatcherSingleActiveConsumer, minted alongside the onlyhavePendingRead = truewrite inreadMoreEntries.readEntreadEntriesFailedcheck it first and **ignore stale completions** (e dispatcher state mutated, DEBUG log), so a superseded read can no long read's flag. This restores the invariant *armed ⇒ havePendingRead=true disconnect-path short-circuit safe: whenever an op is armed,cancelPendingRead()` nowactually cancels it.
PersistentDispatcherSingleActiveConsumerStuckReadTest: a deterministic reproductionusing the real
ManagedLedgerImpl+ManagedCursorImpl+PersistentDispatcherSingleActiveConsumer, with the dispatcher's ordeby a manual task board so the test chooses between the two production-possible arrival
orders of the racing tasks (the stale completion is posted by the BK completion chain, the
redeliver by the client-command thread — both orders occur in production). The repro method
fails on current master and passes with the fix; a negative control ru
scenario in the benign order and passes on both. Fidelity notes (deliv
telescoped ack,
newEntriesCheckDelayInMillis=0as a determinism pinthe class Javadoc.
Notes for reviewers:
redeliver's re-arm bails (e.g. no permits), the disowned read's completion still counts as
current and dispatches — a benign at-least-once duplicate, unchanged from today.
PersistentDispatcherMultipleConsumers/...Classickeep their ownhavePendingRead;their redeliver model is structurally different (replay queue, no rewind + immediate re-arm),
so they are deliberately out of scope here.
readEntriesFailedis@VisibleForTesting; its two test call sites were updated for theadded parameter. No public API is touched.
Verifying this change
This change added tests and can be verified as follows:
PersistentDispatcherSingleActiveConsumerStuckReadTest#testFailoverConsumerStuckWhenRedeliverRacesInFlightReadCompletion— deterministic reproduction of the strand: fails on master, passes with this fix (verified
over repeated forced re-executions, plus the inverse: re-fails when the fix is reverted).
PersistentDispatcherSingleActiveConsumerStuckReadTest#testRedeliverAfterReadCompletionDoesNotStrandCursor— negative control (same staging, benign task order): passes with and without the fix,
showing the arrival order alone is the trigger.
./gradlew :pulsar-broker:test --tests 'org.apache.pulsar.broker.service.persistent.PersistentDispatcherSingleActiveConsumerStuckReadTest' -PtestFailFast=false -PtestRetryCount=0PersistentDispatcherSingleActiveConsumerTest,PersistentTopicTest,FailoverSubscriptionTest(incl. thewaitingCursorsinvariant testsfrom [fix][broker] Fix Broker OOM due to too many waiting cursors and reuse a recycled OpReadEntry incorrectly #24551),
MessageRedeliveryTest.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
Fixes [Bug][broker] Failover subscription stops dispatching forever after active-consumer handover #26164