Skip to content

[fix][broker] Prevent stale read completions from stranding Failover subscriptions#26174

Draft
alexandrebrg wants to merge 1 commit into
apache:masterfrom
alexandrebrg:fix-failover-stuck-read-26164
Draft

[fix][broker] Prevent stale read completions from stranding Failover subscriptions#26174
alexandrebrg wants to merge 1 commit into
apache:masterfrom
alexandrebrg:fix-failover-stuck-read-26164

Conversation

@alexandrebrg

Copy link
Copy Markdown

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 read
the flag refers to — while the cursor state it mirrors is generation-guarded
((op, opReadId) in ManagedCursorImpl).

internalRedeliverUnacknowledgedMessages clears havePendingRead, rewinds, and re-arms a fresh
tail-wait read without cancelling a read that is already in flight
(cursor.cancelPendingReadRequest() can only cancel a waiting op). When the disowned read
later completes, readEntriesComplete / readEntriesFailed run havePendingRead = false as
their first statement — clearing a flag that now describes the newer armed read. Result: the
cursor holds an armed waitingReadOp while the dispatcher believes no read is pending.

The desync is benign while the cursor remains in waitingCursors. It becomes permanent on the
next last-consumer disconnect: cancelPendingRead() short-circuits on the false flag
(if (havePendingRead && ...)) so the armed op survives, and
PersistentSubscription#removeConsumer then removes the cursor from waitingCursors. From
there the subscription is stuck forever:

  • every subsequent arm CAS-fails on the leftover op (ConcurrentWaitCallbackException), which
    readEntriesFailed returns on without rescheduling;
  • addWaitingCursor is only reachable after a successful arm, so the cursor can never re-enter
    waitingCursors;
  • every publish's notifyCursors() polls a queue the cursor is not in.

The consumer stays connected with permits, the backlog grows, msgOutCounter stays 0, and only
a topic unload recovers — exactly the production signature reported in #
(waitingReadOp=true, pendingReadOps=0, waitingCursorsCount=0, cursor state=Open). On
Failover, every redeliver form (ack-timeout, negative ack, explicit redeliver, reconnect epoch
bump) funnels into internalRedeliverUnacknowledgedMessages, so any of
read completion can mint the desync.

Modifications

  • Add a monotonic readGeneration (plain long, guarded by the dispatcher monitor) to
    PersistentDispatcherSingleActiveConsumer, minted alongside the only
    havePendingRead = true write in readMoreEntries.
  • Capture the generation into the read-completion continuation; readEnt readEntriesFailedcheck 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()` now
    actually cancels it.
  • Add PersistentDispatcherSingleActiveConsumerStuckReadTest: a deterministic reproduction
    using the real ManagedLedgerImpl + ManagedCursorImpl +
    PersistentDispatcherSingleActiveConsumer, with the dispatcher's orde
    by 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=0 as a determinism pin
    the class Javadoc.

Notes for reviewers:

  • The fix is completion-side. One narrow corner intentionally keeps master's behavior: if the
    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 / ...Classic keep their own havePendingRead;
    their redeliver model is structurally different (replay queue, no rewind + immediate re-arm),
    so they are deliberately out of scope here.
  • readEntriesFailed is @VisibleForTesting; its two test call sites were updated for the
    added parameter. No public API is touched.

Verifying this change

  • Make sure that the change passes the CI checks.

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.
  • Run:
    ./gradlew :pulsar-broker:test --tests 'org.apache.pulsar.broker.service.persistent.PersistentDispatcherSingleActiveConsumerStuckReadTest' -PtestFailFast=false -PtestRetryCount=0
  • Existing coverage exercised and green: PersistentDispatcherSingleActiveConsumerTest,
    PersistentTopicTest, FailoverSubscriptionTest (incl. the waitingCursors invariant tests
    from [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

…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>
// whenever readMoreEntries issues a read; a completion mutates dispatcher state only when its captured
// generation still matches, so a completion whose read was disowned by a redeliver-driven rewind + re-arm
// is ignored instead of clearing havePendingRead for the newer outstanding read. Guarded by "this".
private long readGeneration = 0L;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"epoch" is a currently used concept in Pulsar, could use "read0pEpoch" for better clarity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][broker] Failover subscription stops dispatching forever after active-consumer handover

2 participants