-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[fix][broker] Clear delayed delivery state before resetting the cursor #26420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ | |
| import java.util.TreeMap; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.CompletableFuture; | ||
| import java.util.concurrent.CompletionException; | ||
| import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; | ||
| import java.util.concurrent.atomic.AtomicLong; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
|
|
@@ -54,6 +55,7 @@ | |
| import org.apache.bookkeeper.mledger.ScanOutcome; | ||
| import org.apache.commons.lang3.tuple.MutablePair; | ||
| import org.apache.pulsar.broker.ServiceConfiguration; | ||
| import org.apache.pulsar.broker.delayed.BucketDelayedDeliveryTrackerFactory; | ||
| import org.apache.pulsar.broker.intercept.BrokerInterceptor; | ||
| import org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl; | ||
| import org.apache.pulsar.broker.loadbalance.extensions.data.BrokerLookupData; | ||
|
|
@@ -983,88 +985,108 @@ private CompletableFuture<Void> resetCursorInternal(Position finalPosition, Comp | |
| } | ||
| } | ||
|
|
||
| disconnectFuture.whenComplete((aVoid, throwable) -> { | ||
| if (dispatcher != null) { | ||
| dispatcher.resetCloseFuture(); | ||
| } | ||
|
|
||
| if (throwable != null) { | ||
| log.error() | ||
| .exception(throwable) | ||
| .log("Failed to disconnect consumer from subscription"); | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| future.completeExceptionally( | ||
| new SubscriptionBusyException("Failed to disconnect consumers from subscription")); | ||
| return; | ||
| } | ||
| disconnectFuture | ||
| .handle((ignore, throwable) -> { | ||
| if (dispatcher != null) { | ||
| dispatcher.resetCloseFuture(); | ||
| } | ||
|
|
||
| log.info() | ||
| .log("Successfully disconnected consumers from subscription, proceeding with cursor reset"); | ||
| if (throwable != null) { | ||
| log.error() | ||
| .exception(throwable) | ||
| .log("Failed to disconnect consumer from subscription"); | ||
|
|
||
| CompletableFuture<Boolean> forceReset = new CompletableFuture<>(); | ||
| if (topic.getTopicCompactionService() == null) { | ||
| forceReset.complete(false); | ||
| } else { | ||
| topic.getTopicCompactionService().getLastCompactedPosition().thenAccept(lastCompactedPosition -> { | ||
| Position resetTo = finalPosition; | ||
| if (lastCompactedPosition != null && resetTo.compareTo(lastCompactedPosition.getLedgerId(), | ||
| lastCompactedPosition.getEntryId()) <= 0) { | ||
| forceReset.complete(true); | ||
| } else { | ||
| forceReset.complete(false); | ||
| throw new CompletionException( | ||
| new SubscriptionBusyException( | ||
| "Failed to disconnect consumers from subscription")); | ||
| } | ||
| }).exceptionally(ex -> { | ||
| forceReset.completeExceptionally(ex); | ||
|
|
||
| log.info() | ||
| .log("Successfully disconnected consumers from subscription, proceeding with cursor reset"); | ||
| return null; | ||
| }); | ||
| } | ||
| }) | ||
| .thenCompose(__ -> { | ||
| if (dispatcher != null) { | ||
| return dispatcher.clearDelayedMessages(); | ||
| } | ||
|
|
||
| forceReset.thenAccept(forceResetValue -> { | ||
| cursor.asyncResetCursor(finalPosition, forceResetValue, new AsyncCallbacks.ResetCursorCallback() { | ||
| @Override | ||
| public void resetComplete(Object ctx) { | ||
| log.debug() | ||
| .attr("finalPosition", finalPosition) | ||
| .log("Successfully reset subscription to position"); | ||
| if (dispatcher != null) { | ||
| dispatcher.cursorIsReset(); | ||
| dispatcher.afterAckMessages(null, finalPosition); | ||
| } | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| future.complete(null); | ||
| if (topic.isDelayedDeliveryEnabled() | ||
| && topic.getBrokerService().getDelayedDeliveryTrackerFactory() | ||
| instanceof BucketDelayedDeliveryTrackerFactory bucketDelayedDeliveryTrackerFactory) { | ||
| return bucketDelayedDeliveryTrackerFactory.cleanResidualSnapshots(cursor); | ||
| } | ||
|
|
||
| @Override | ||
| public void resetFailed(ManagedLedgerException exception, Object ctx) { | ||
| log.error() | ||
| .attr("finalPosition", finalPosition) | ||
| .exception(exception) | ||
| .log("Failed to reset subscription to position"); | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| // todo - retry on InvalidCursorPositionException | ||
| // or should we just ask user to retry one more time? | ||
| if (exception instanceof InvalidCursorPositionException) { | ||
| future.completeExceptionally(new SubscriptionInvalidCursorPosition(exception.getMessage())); | ||
| } else if (exception instanceof ConcurrentFindCursorPositionException) { | ||
| future.completeExceptionally(new SubscriptionBusyException(exception.getMessage())); | ||
| } else { | ||
| future.completeExceptionally(new BrokerServiceException(exception)); | ||
| } | ||
| return CompletableFuture.completedFuture(null); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] the This branch returns without cleaning anything, so a reset on a subscription with no dispatcher leaves the pre-reset The asymmetry is inside this very method. When a dispatcher does exist but has no tracker yet, Reachability. Failure scenario. Bucket delayed delivery enabled; a Shared subscription has tracked delayed messages and persisted bucket snapshots; the topic is unloaded; with no consumer connected an operator resets the cursor forward. The snapshots survive the reset, and the first consumer to connect rebuilds the tracker from them ( The description asserts this branch is safe ("the recovered bucket snapshots stay valid for the replay ... re-added messages dedup through the index bitmap"). That argument holds for a backward reset, but not for a forward one. Either route
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f16e705. I was originally fine with the skip because re-added messages dedup through the index bitmap, but that argument only holds for reset-to-earliest: getScheduledMessages filters by the ledger GC boundary, not by the reset position, so with a reset to a later position the recovered snapshots would still hand out the skipped messages. The no-dispatcher branch now runs cleanResidualSnapshots(cursor) behind the same guard as the unsubscribe path, and testResetCursorWithoutDispatcherCleansResidualBucketSnapshots covers it (close the consumer, unload the topic, reset, assert the properties are gone). |
||
| }) | ||
| .thenCompose(__ -> { | ||
| CompletableFuture<Boolean> forceReset = new CompletableFuture<>(); | ||
| if (topic.getTopicCompactionService() == null) { | ||
| forceReset.complete(false); | ||
| } else { | ||
| topic.getTopicCompactionService().getLastCompactedPosition() | ||
| .thenAccept(lastCompactedPosition -> { | ||
| Position resetTo = finalPosition; | ||
| if (lastCompactedPosition != null | ||
| && resetTo.compareTo(lastCompactedPosition.getLedgerId(), | ||
| lastCompactedPosition.getEntryId()) <= 0) { | ||
| forceReset.complete(true); | ||
| } else { | ||
| forceReset.complete(false); | ||
| } | ||
| }).exceptionally(ex -> { | ||
| forceReset.completeExceptionally(ex); | ||
| return null; | ||
| }); | ||
| } | ||
| return forceReset; | ||
| }) | ||
| .thenAccept(forceResetValue -> { | ||
| cursor.asyncResetCursor(finalPosition, forceResetValue, new AsyncCallbacks.ResetCursorCallback() { | ||
| @Override | ||
| public void resetComplete(Object ctx) { | ||
| log.debug() | ||
| .attr("finalPosition", finalPosition) | ||
| .log("Successfully reset subscription to position"); | ||
| if (dispatcher != null) { | ||
| dispatcher.cursorIsReset(); | ||
| dispatcher.afterAckMessages(null, finalPosition); | ||
| } | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| future.complete(null); | ||
| } | ||
|
|
||
| @Override | ||
| public void resetFailed(ManagedLedgerException exception, Object ctx) { | ||
| log.error() | ||
| .attr("finalPosition", finalPosition) | ||
| .exception(exception) | ||
| .log("Failed to reset subscription to position"); | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| // todo - retry on InvalidCursorPositionException | ||
| // or should we just ask user to retry one more time? | ||
| if (exception instanceof InvalidCursorPositionException) { | ||
| future.completeExceptionally( | ||
| new SubscriptionInvalidCursorPosition(exception.getMessage())); | ||
| } else if (exception instanceof ConcurrentFindCursorPositionException) { | ||
| future.completeExceptionally(new SubscriptionBusyException(exception.getMessage())); | ||
| } else { | ||
| future.completeExceptionally(new BrokerServiceException(exception)); | ||
| } | ||
| } | ||
| }); | ||
| }).exceptionally((e) -> { | ||
| log.error() | ||
| .exception(e) | ||
| .log("Error while resetting cursor"); | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| Throwable cause = FutureUtil.unwrapCompletionException(e); | ||
| future.completeExceptionally(cause instanceof BrokerServiceException exception | ||
| ? exception : new BrokerServiceException(cause)); | ||
| return null; | ||
| }); | ||
| }).exceptionally((e) -> { | ||
| log.error() | ||
| .exception(e) | ||
| .log("Error while resetting cursor"); | ||
| IS_FENCED_UPDATER.set(PersistentSubscription.this, FALSE); | ||
| inProgressResetCursorFuture = null; | ||
| future.completeExceptionally(new BrokerServiceException(e)); | ||
| return null; | ||
| }); | ||
| }); | ||
| return future; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
| import static org.testng.Assert.assertEquals; | ||
| import static org.testng.Assert.assertFalse; | ||
| import static org.testng.Assert.assertNotNull; | ||
| import static org.testng.Assert.assertNull; | ||
| import static org.testng.Assert.assertTrue; | ||
| import com.google.common.collect.Multimap; | ||
| import java.io.ByteArrayOutputStream; | ||
|
|
@@ -128,6 +129,98 @@ public void testBucketDelayedDeliveryWithAllConsumersDisconnecting() throws Exce | |
| Assert.assertEquals(bucketKeys, bucketKeys2); | ||
| } | ||
|
|
||
| @Test | ||
| public void testResetCursorClearsDelayedMessages() throws Exception { | ||
| String topic = BrokerTestUtil.newUniqueName("persistent://public/default/testResetClearsDelayed"); | ||
|
|
||
| @Cleanup | ||
| Consumer<String> consumer = pulsarClient.newConsumer(Schema.STRING) | ||
| .topic(topic) | ||
| .subscriptionName("sub") | ||
| .subscriptionType(SubscriptionType.Shared) | ||
| .subscribe(); | ||
|
|
||
| @Cleanup | ||
| Producer<String> producer = pulsarClient.newProducer(Schema.STRING) | ||
| .topic(topic) | ||
| .create(); | ||
|
|
||
| for (int i = 0; i < 100; i++) { | ||
| producer.newMessage() | ||
| .value("msg") | ||
| .deliverAfter(1, TimeUnit.HOURS) | ||
| .send(); | ||
| } | ||
|
|
||
| Dispatcher dispatcher = pulsar.getBrokerService().getTopicReference(topic) | ||
| .get().getSubscription("sub").getDispatcher(); | ||
| Awaitility.await().untilAsserted(() -> | ||
| Assert.assertEquals(dispatcher.getNumberOfDelayedMessages(), 100)); | ||
| List<String> bucketKeys = | ||
| ((AbstractPersistentDispatcherMultipleConsumers) dispatcher).getCursor().getCursorProperties() | ||
| .keySet().stream().filter(x -> x.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX)).toList(); | ||
| assertFalse(bucketKeys.isEmpty()); | ||
|
|
||
| consumer.close(); | ||
|
|
||
| admin.topics().resetCursor(topic, "sub", MessageId.earliest); | ||
|
|
||
| assertEquals(dispatcher.getNumberOfDelayedMessages(), 0, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] the new test races the client's automatic reconnect, which can re-track the delayed messages before the assertions run These two assertions are unguarded, and the comment above the reset states the no-re-tracking assumption without enforcing it. The reset disconnects the consumer via Failure scenario. On a loaded CI runner, the reset itself (bucket snapshot deletes over metadata plus the cursor ledger write) plus scheduling jitter exceeds the ~100 ms backoff that started when the consumer was disconnected at the top of the reset. The consumer resubscribes and re-tracks before this line runs, so Suggested fix: Worth adding while you are here: the test covers neither the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in f16e705, consumer.close() before the reset. The dispatcher survives the disconnect, so the reset still clears the tracker it populated and the test still fails on master without the fix. Closing the consumer first is also what flushed out the clear() race from the comment above, so the ordering matters beyond the assertions. |
||
| "The delayed delivery tracker should be cleared by the cursor reset"); | ||
| List<String> bucketKeysAfterReset = | ||
| ((AbstractPersistentDispatcherMultipleConsumers) dispatcher).getCursor().getCursorProperties() | ||
| .keySet().stream().filter(x -> x.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX)).toList(); | ||
| assertTrue(bucketKeysAfterReset.isEmpty(), | ||
| "The bucket cursor properties should be removed by the cursor reset"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testResetCursorWithoutDispatcherCleansResidualBucketSnapshots() throws Exception { | ||
| String topic = BrokerTestUtil.newUniqueName("persistent://public/default/testResetNoDispatcher"); | ||
|
|
||
| @Cleanup | ||
| Consumer<String> consumer = pulsarClient.newConsumer(Schema.STRING) | ||
| .topic(topic) | ||
| .subscriptionName("sub") | ||
| .subscriptionType(SubscriptionType.Shared) | ||
| .subscribe(); | ||
|
|
||
| @Cleanup | ||
| Producer<String> producer = pulsarClient.newProducer(Schema.STRING) | ||
| .topic(topic) | ||
| .create(); | ||
|
|
||
| for (int i = 0; i < 100; i++) { | ||
| producer.newMessage() | ||
| .value("msg") | ||
| .deliverAfter(1, TimeUnit.HOURS) | ||
| .send(); | ||
| } | ||
|
|
||
| PersistentSubscription subscription = (PersistentSubscription) pulsar.getBrokerService() | ||
| .getTopicReference(topic).get().getSubscription("sub"); | ||
| Dispatcher dispatcher = subscription.getDispatcher(); | ||
| Awaitility.await().untilAsserted(() -> | ||
| Assert.assertEquals(dispatcher.getNumberOfDelayedMessages(), 100)); | ||
| List<String> bucketKeys = subscription.getCursor().getCursorProperties().keySet().stream() | ||
| .filter(x -> x.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX)).toList(); | ||
| assertFalse(bucketKeys.isEmpty()); | ||
|
|
||
| consumer.close(); | ||
| admin.topics().unload(topic); | ||
|
|
||
| admin.topics().resetCursor(topic, "sub", MessageId.earliest); | ||
|
|
||
| PersistentSubscription reloadedSubscription = (PersistentSubscription) pulsar.getBrokerService() | ||
| .getTopicReference(topic).get().getSubscription("sub"); | ||
| assertNull(reloadedSubscription.getDispatcher(), | ||
| "No consumer has connected since the topic was reloaded"); | ||
| List<String> bucketKeysAfterReset = reloadedSubscription.getCursor().getCursorProperties() | ||
| .keySet().stream().filter(x -> x.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX)).toList(); | ||
| assertTrue(bucketKeysAfterReset.isEmpty(), | ||
| "A reset without a dispatcher should still remove the residual bucket cursor properties"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIncrementPartitionsDoesNotCopyBucketDelayedDeliveryState() throws Exception { | ||
| String topic = BrokerTestUtil.newUniqueName("persistent://public/default/testBucketStatePartitionExpansion"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[INTENT MISMATCH]
clear()awaits in-flight trims but not an in-flight bucket segment load, so the "in-flight loads could race" motivation is only partly addressedThe second motivation bullet says the fix stops "in-flight trims/loads/deletes from before the reset" racing the replayed state. Awaiting
clearDelayedMessages()here covers trims and deletes, but not loads.BucketDelayedDeliveryTracker.clear()(:795-816) chains only offtrimFuture. The in-flight segment load tracked inpendingLoad(declared at:131, assigned at:717) is never awaited, and its continuation (:718-734) re-populatessnapshotSegmentLastIndexMapandsharedBucketPriorityQueue. Both that continuation and the body ofclear()(:806) take the tracker monitor, so they are mutually exclusive but unordered.Failure scenario. A bucket segment load is already in flight when the reset disconnects consumers.
clear()empties the queues andcleanImmutableBuckets()removes the immutable buckets; the outstanding load's continuation then re-inserts pre-reset indexes intosharedBucketPriorityQueueand re-registers a bucket that was just removed.asyncResetCursorproceeds against a tracker that is not actually empty, which is the state this PR set out to prevent.To be fair on scope: this is a pre-existing property of
clear()(shared withclearBacklogand unsubscribe), not a regression introduced here, and the window is narrow — consumers are already disconnected by this point, so only a load started before the reset can land. But since the stated purpose of the change is to make the reset wait for the delayed state to settle, it is worth either awaitingpendingLoadinclear()or narrowing the second motivation bullet.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, and it actually bit me while testing the reconnect fix in the test below. With the consumer closed before the reset, the full BucketDelayedDeliveryTest suite fails for me every single time: the property removal from the bucket delete still races asyncResetCursor persistence and the reset fails with BadVersion ("unable to persist readPosition for cursor reset"). Same pre-existing clear() gap you describe here. #26401 (still open) is where this gets fixed - picking 7f22e56 on top of this branch makes the suite pass again, verified locally. I would rather not duplicate that work in this PR, so the plan is to rebase onto #26401 once it lands.