-
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 |
|---|---|---|
|
|
@@ -983,88 +983,83 @@ 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; | ||
| } | ||
|
|
||
| log.info() | ||
| .log("Successfully disconnected consumers from subscription, proceeding with cursor reset"); | ||
|
|
||
| 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 { | ||
| disconnectFuture | ||
| .thenCompose(__ -> { | ||
| log.info() | ||
| .log("Successfully disconnected consumers from subscription, proceeding with cursor reset"); | ||
| if (dispatcher != null) { | ||
| dispatcher.resetCloseFuture(); | ||
| return dispatcher.clearDelayedMessages(); | ||
|
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. [INTENT MISMATCH] The second motivation bullet says the fix stops "in-flight trims/loads/deletes from before the reset" racing the replayed state. Awaiting
Failure scenario. A bucket segment load is already in flight when the reset disconnects consumers. To be fair on scope: this is a pre-existing property of |
||
| } | ||
| 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 |
||
| }) | ||
| .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; | ||
| }); | ||
| } | ||
| }).exceptionally(ex -> { | ||
| forceReset.completeExceptionally(ex); | ||
| return null; | ||
| }); | ||
| } | ||
|
|
||
| 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); | ||
| 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); | ||
| } | ||
| 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)); | ||
| @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; | ||
| future.completeExceptionally(new BrokerServiceException(e)); | ||
| 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 |
|---|---|---|
|
|
@@ -128,6 +128,51 @@ 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()); | ||
|
|
||
| // Resetting the cursor disconnects the consumer, so nothing gets re-tracked while we | ||
| // observe the post-reset state. | ||
| 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 |
||
| "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 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.
[QUALITY] the whenComplete -> thenCompose rewrite silently dropped the disconnect-failure branch (resetCloseFuture skipped, 412 becomes 500) — currently unreachable, flagging so the deletion is deliberate
Moving from
whenCompletetothenComposechanged what happens whendisconnectFuturecompletes exceptionally. The base revision ran, on either outcome:thenComposeskips its body when the upstream future fails, soresetCloseFuture()no longer runs — leaving a stalecloseFutureon the dispatcher (PersistentDispatcherMultipleConsumers.java:657-659) — and the terminal.exceptionallysurfacesBrokerServiceExceptioninstead. That would change the admin response:SubscriptionBusyExceptionmaps to 412 PRECONDITION_FAILED (PersistentTopicsBase.java:2816-2822and:2524-2530), while anything else falls throughresumeAsyncResponseExceptionallyto 500.This is currently unreachable, so it is a latent semantics change rather than a live bug: in every persistent dispatcher
closeFutureis only ever completed withcomplete(null)(PersistentDispatcherMultipleConsumers.java:631-641,PersistentDispatcherMultipleConsumersClassic.java:541-553,AbstractDispatcherSingleActiveConsumer.java:337-343). I am flagging it only so the removal is deliberate rather than incidental to the reflow — if the branch is genuinely dead, dropping it is fine, but it would be good to say so in the PR description.