[fix][broker] Fix replication stall when a cursor rewind skips an in-flight read - #26106
Conversation
…flight read ### Motivation When a publish to the remote cluster fails, `PersistentReplicator` rewinds the cursor (`beforeTerminateOrCursorRewinding(Failed_Publishing)` + `doRewindCursor`) so the unacked messages are re-read. If a cursor read was already dispatched at that moment, `cursor.cancelPendingReadRequest()` returns false (there is no waiting read op to cancel — the common case when there is a backlog), so `cancelPendingReadTasks()` only flags the in-flight task and does not complete it. When that dispatched read later completes, `readEntriesComplete()` discarded the result in the skip branch but left the task with `entries == null`. This caused a two-fold stall: 1. The task stayed `entries == null` forever, so `hasPendingRead()` stayed true and `getPermitsIfNoPendingRead()` returned 0 — no further reads were ever issued. 2. `ManagedCursor` advances the read position when a read completes, so the discarded read moved the read position past the still-unacked messages, overwriting the rewind from `doRewindCursor()`. Even once reads resumed, those messages were stranded behind the read position and never re-read. The backlog therefore never drained. This is independent of the replicator dispatch rate-limiter path; it exists on master regardless of PR apache#26005. ### Modifications In the `readEntriesComplete()` skip branch, when the result is discarded due to a cursor rewind and the replicator is not terminating: - Rewind the cursor again so the messages stranded by the completing read are re-read, and complete the task with an empty result so it releases its permit and stops counting as a pending read. Both are done under `synchronized(inFlightTasks)` — the lock `doRewindCursor()` uses — so they are atomic with respect to `readMoreEntries()`. - Resume reading on the broker executor instead of calling `readMoreEntries()` directly, to avoid deep `readEntriesComplete -> readMoreEntries` recursion when a cursor read completes inline (StackOverflowError), the same hazard `PersistentDispatcherMultipleConsumers.readMoreEntriesAsync()` guards against. ### Tests - `testCursorRewindSkippedReadCompletesInFlightTask`: a deterministic unit test that a skip-flagged in-flight read completes the task and frees the read slot. - `testReplicationRecoversAfterPublishFailureRewindWithInflightRead`: an end-to-end test over two clusters that holds a real cursor read in flight, rewinds the cursor the same way a failed publish does, then asserts the backlog drains and every message is delivered to the remote cluster. Both tests fail on master and pass with the fix. Assisted-by: Claude Code (Opus 4.8)
void-ptr974
left a comment
There was a problem hiding this comment.
LGTM. I checked the skipped in-flight read path after cursor rewind: the task is now completed so permits are released, the cursor is rewound again to undo the read-position advance from the discarded read, and reads resume asynchronously to avoid recursion. The added unit and e2e replication tests cover both the leaked pending task and backlog recovery. Nice, precise fix for a subtle race.
…covery Follow-up to the cursor-rewind stall fix in the same PR: - Document why re-rewinding to the mark-delete position is safe: only unacked messages are re-read, and entries that were already acknowledged (replicated) are skipped on the re-read. Recovery is therefore at-least-once, with duplicates bounded to the messages that were in flight when the rewind happened. - Strengthen the end-to-end recovery test to verify message content: every produced message is delivered to the remote cluster exactly once (no loss, and no duplicates in this single-batch scenario). Assisted-by: Claude Code (Opus 4.8)
Resolved conflict in PersistentReplicatorInflightTaskTest.java by combining the apache#26005 read-scheduling test changes (from master) with this PR's added imports and tests (testCursorRewindSkippedReadCompletesInFlightTask, testReplicationRecoversAfterPublishFailureRewindWithInflightRead). PersistentReplicator.java auto-merged: this PR's readEntriesComplete skip-branch fix and apache#26005's getReadLimits read-batching are both retained. Assisted-by: Claude Code (Claude Opus 4.8) Claude-Session: https://claude.ai/code/session_01CMKzAniMcNHvk1jBh6bEKQ
… instead of removed helper The skip-branch comment referenced acquirePermitsIfNotFetchingSchema(), which apache#26005 removed. Update it to point at readMoreEntries()'s read-scheduling path (the path that, under the inFlightTasks lock, reads both the free read slot and the cursor read position), so the concurrency explanation matches the current code. Comment-only change; no behavior change. Addresses review feedback on PR apache#26106.
|
Recent CI failure where this production issue showed up: https://github.com/apache/pulsar/actions/runs/28466180778/attempts/1?pr=26122 That run is on an unrelated PR (#26122, a different flaky-test fix) whose branch is based on Line 414 is Why this is the same bug this PR fixesIt is the same
So |
…r-rewind stall Add testReplicationRecoversAfterSchemaFetchRewindWithInflightRead to PersistentReplicatorInflightTaskTest: a deterministic end-to-end test that holds a real cursor read in flight and rewinds the cursor through the schema-fetch path (beforeTerminateOrCursorRewinding(Fetching_Schema) + doRewindCursor(true), the same two rewind calls GeoPersistentReplicator.replicateEntries issues at a schema boundary), then asserts the backlog drains and every message is replicated to the remote cluster. This complements testReplicationRecoversAfterPublishFailureRewindWithInflightRead (the Failed_Publishing variant) and is the deterministic counterpart to the flaky ReplicatorTest.testReplicationWithSchema. The test fails on master (the backlog stays stuck at the full message count) and passes with the fix. Assisted-by: Claude Code (Opus 4.8)
Why #26005 makes this stall much more likely (and why
|
|
Merging this PR since CI fails due to the bug that this PR fixes. The bug started appearing more often after PR #26005 as explained in the previous comment. |
Motivation
When a publish to the remote cluster fails,
PersistentReplicatorrewinds the cursor(
beforeTerminateOrCursorRewinding(Failed_Publishing)+doRewindCursor) so the unacked messagesare re-read. If a cursor read was already dispatched at that moment,
cursor.cancelPendingReadRequest()returns
false(there is no waiting read op to cancel — the common case when there is a backlog), socancelPendingReadTasks()only flags the in-flight task and does not complete it.When that dispatched read later completes,
readEntriesComplete()discarded the result in the skipbranch but left the task with
entries == null. This caused a two-fold stall:entries == nullforever, sohasPendingRead()stayedtrueandgetPermitsIfNoPendingRead()returned0— no further reads were ever issued.ManagedCursoradvances the read position when a read completes(
OpReadEntry→ManagedCursorImpl.setReadPosition), so the discarded read moved the read positionpast the still-unacked messages, overwriting the rewind from
doRewindCursor(). Even once readsresumed, those messages were stranded behind the read position and never re-read.
The geo-replication backlog therefore never drained. This is independent of the replicator dispatch
rate-limiter path; it exists on
masterregardless of #26005 (it was found while reviewing that PR).The same defect is reachable from any cursor rewind that races an already-dispatched read, not only a
failed publish — in particular the rewind performed when a new schema is detected
(
beforeTerminateOrCursorRewinding(Fetching_Schema)+doRewindCursor(true)inGeoPersistentReplicator.replicateEntries). It has been observed in CI as flaky failures ofReplicatorTest.testReplicationWithSchema, where replication stalled at thePersonOne→PersonThreeschema boundary and the remote-cluster consumers timed out. A concrete recent occurrenceis this run (on an
unrelated PR whose branch is based on
masterwithout this fix):Broker Group 5failed intestReplicationWithSchemawithAssertionError: expected [true] but found [false]atReplicatorTest.java:414—assertTrue(msg1 != null && msg2 != null && msg3 != null)— because aremote consumer's
receive(10, SECONDS)returnednullfor a message past the schema boundary. Thatrace only reproduces under the slower I/O timing of CI (a read must already be dispatched to bookies
when the rewind runs), which is why it is hard to reproduce on a fast local machine; the added unit/e2e
tests reproduce it deterministically. (No separate flaky-test tracking issue was filed for that
symptom.)
Modifications
In the
readEntriesComplete()skip branch, when the read result is discarded due to a cursor rewindand the replicator is not terminating:
task with an empty result so it releases its permit and stops counting as a pending read. Both are
done under
synchronized(inFlightTasks)— the same lockdoRewindCursor()uses — so they are atomicwith respect to
readMoreEntries().readMoreEntries()directly, to avoid deepreadEntriesComplete -> readMoreEntriesrecursion when a cursor read completes inline(
StackOverflowError), the same hazardPersistentDispatcherMultipleConsumers.readMoreEntriesAsync()guards against.
This also removes the per-entry
incCompletedEntries()call the skip branch previously made. That callcould never complete the task:
incCompletedEntries()only advancescompletedEntrieswhen thetask's own
entriesfield is non-empty, but at this point inreadEntriesComplete()the readresult exists only as the method parameter — the task's
entriesfield is assigned later, in thenormal (non-skip) path that this branch
returns before reaching, so it is stillnullhere. The calltherefore always fell into the
elsebranch, loggingUnexpected calling of increase completed entriesonce per entry (these are the errors seen in the stalled-replication logs) withoutincrementing anything or making
isDone()returntrue. In other words it looked like it completedthe task but did nothing, which is the core reason the task leaked. Completing the task with
setEntries(Collections.emptyList())(above) is what actually marks it done —isDone()returnstruefor an empty result — so the permit is released; the entry buffers are still freed viaentry.release().Note on semantics: the recovery rewind resets the read position to the mark-delete position, so only
unacked messages are re-read — entries already acknowledged (replicated) are skipped on the
re-read. Recovery is therefore at-least-once, with duplicates bounded to the messages that were in
flight when the rewind happened (the prior behavior delivered nothing at all on this path).
Verifying this change
This change added tests and can be verified as follows:
./gradlew :pulsar-broker:test --tests "org.apache.pulsar.broker.service.persistent.PersistentReplicatorInflightTaskTest.testCursorRewindSkippedReadCompletesInFlightTask"— a deterministic unit test that a skip-flagged in-flight read completes the task and frees the read slot.
./gradlew :pulsar-broker:test --tests "org.apache.pulsar.broker.service.persistent.PersistentReplicatorInflightTaskTest.testReplicationRecoversAfterPublishFailureRewindWithInflightRead"— an end-to-end test over two clusters that holds a real cursor read in flight, rewinds the cursor the
same way a failed publish does (
Failed_Publishing+doRewindCursor(false)), then asserts thebacklog drains and every produced message is delivered to the remote cluster exactly once (no loss, no
duplicates).
./gradlew :pulsar-broker:test --tests "org.apache.pulsar.broker.service.persistent.PersistentReplicatorInflightTaskTest.testReplicationRecoversAfterSchemaFetchRewindWithInflightRead"— an end-to-end test that holds a real cursor read in flight and rewinds the cursor through the
schema-fetch path: the same two rewind calls (
beforeTerminateOrCursorRewinding(Fetching_Schema)doRewindCursor(true)) thatGeoPersistentReplicator.replicateEntriesissues at a schema boundary,then asserts the backlog drains and every produced message is delivered to the remote cluster. This is
the deterministic counterpart to the flaky
ReplicatorTest.testReplicationWithSchema. The rewind isdriven directly (rather than by crossing a real schema boundary) because the bug needs a read to be in
flight at the exact moment the rewind runs, which on the natural path depends on a read being pipelined
— dispatched by an earlier message's
sendComplete— concurrently withreplicateEntriesreaching theschema boundary, an inherently racy interleaving. The test therefore guards the
readEntriesCompleteskip-branch recovery for the
Fetching_Schemarewind, not the schema-detection wiring itself.All three tests fail on
master(the e2e tests time out with the backlog stuck at the full messagecount) and pass with the fix; the e2e tests were run repeatedly (
invocationCount5–10) with noflakiness.
Does this pull request potentially affect one of the following parts: