-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[fix][client] PIP-486: explicit unsubscribe for scalable consumers' clean close #26433
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 |
|---|---|---|
|
|
@@ -142,6 +142,7 @@ | |
| import org.apache.pulsar.common.api.proto.CommandScalableTopicClose; | ||
| import org.apache.pulsar.common.api.proto.CommandScalableTopicLookup; | ||
| import org.apache.pulsar.common.api.proto.CommandScalableTopicSubscribe; | ||
| import org.apache.pulsar.common.api.proto.CommandScalableTopicUnsubscribe; | ||
| import org.apache.pulsar.common.api.proto.CommandSeek; | ||
| import org.apache.pulsar.common.api.proto.CommandSend; | ||
| import org.apache.pulsar.common.api.proto.CommandSubscribe; | ||
|
|
@@ -1177,6 +1178,45 @@ protected void handleCommandScalableTopicSubscribe( | |
| }); | ||
| } | ||
|
|
||
| @Override | ||
| protected void handleCommandScalableTopicUnsubscribe( | ||
| CommandScalableTopicUnsubscribe commandScalableTopicUnsubscribe) { | ||
| checkArgument(state == State.Connected); | ||
| final long requestId = commandScalableTopicUnsubscribe.getRequestId(); | ||
| final long consumerId = commandScalableTopicUnsubscribe.getConsumerId(); | ||
|
|
||
| // The lookup is scoped to this connection's own registrations, so a client can only | ||
| // unregister sessions it created here — no further authorization is needed. | ||
| ScalableConsumerRegistrationRef ref = scalableConsumerRegistrations.get(consumerId); | ||
| var scalableTopicService = service.getScalableTopicService(); | ||
| if (ref == null || scalableTopicService == null) { | ||
| // Unknown or already swept by a disconnect: idempotent success. | ||
| getCommandSender().sendSuccessResponse(requestId); | ||
| return; | ||
| } | ||
| log.debug().attr("topic", ref.topicName()).attr("subscription", ref.subscription()) | ||
| .attr("consumerName", ref.consumerName()).attr("requestId", requestId) | ||
| .log("Received ScalableTopicUnsubscribe"); | ||
| scalableTopicService.unregisterConsumer(ref.topicName(), ref.subscription(), ref.consumerName()) | ||
| .whenCompleteAsync((__, ex) -> { | ||
| if (ex != null) { | ||
| // Keep the ref: the channelInactive sweep can still report the | ||
| // disconnect, so the grace-period fallback stays alive for a | ||
| // registration the explicit unregister failed to delete. | ||
|
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 grace-period fallback this comment promises is not preserved: a failed metadata delete leaves the group wedged with no timer and no rebalance The comment on these lines states the invariant the whole error path rests on: "Keep the ref: the channelInactive sweep can still report the disconnect, so the grace-period fallback stays alive for a registration the explicit unregister failed to delete." It does not hold.
ConsumerSession removed = sessions.remove(consumerName);
if (removed == null) { return CompletableFuture.completedFuture(snapshotAssignments()); }
removed.cancelGraceTimer();
return resources.unregisterConsumerAsync(topicName, subscriptionName, consumerName)
.thenApply(__ -> { ... rebalanceAndNotify() ... });If the delete fails, the future fails and Failure scenario. A metadata-store error (session loss, store unavailable) during an ordinary clean consumer close. Afterwards: the in-memory session is gone, Two related notes:
Moving |
||
| Throwable cause = ex.getCause() != null ? ex.getCause() : ex; | ||
| log.warn().attr("consumerName", ref.consumerName()).exceptionMessage(cause) | ||
| .log("ScalableTopicUnsubscribe failed"); | ||
| getCommandSender().sendErrorResponse(requestId, ServerError.UnknownError, | ||
| cause.getMessage()); | ||
| return; | ||
| } | ||
| // Removed only on success; a channelInactive racing the unregister just | ||
| // re-reports an already-removed session, which the coordinator ignores. | ||
| scalableConsumerRegistrations.remove(consumerId, ref); | ||
| getCommandSender().sendSuccessResponse(requestId); | ||
| }, ctx.executor()); | ||
| } | ||
|
|
||
| @Override | ||
| protected void handlePartitionMetadataRequest(CommandPartitionedTopicMetadata partitionMetadataParam) { | ||
| checkArgument(state == State.Connected); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -316,6 +316,21 @@ public void onConsumerDisconnect(TopicName topic, String subscription, String co | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Explicit clean leave: forwards to the locally-held controller, which deletes the | ||
| * persisted registration and rebalances the remaining consumers immediately. No-op when | ||
| * the controller is not held locally (e.g. leadership moved) — the disconnect grace | ||
|
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 javadoc's 'e.g. leadership moved' names a case that cannot reach this no-op branch Small, but it misdescribes the fallback contract my I first read this as a fallback-loss bug, and that reading is wrong; the counter-argument is worth recording: leadership movement does not remove the So the branch is fine; the example in the javadoc names a case that cannot reach it, which is misleading in a comment whose whole job is to explain when the grace-period fallback still applies. Suggest replacing "e.g. leadership moved" with the cases that actually reach it (topic deleted, service shutting down, or a consumer that never registered here).
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 javadoc's 'e.g. leadership moved' names a case that cannot reach this no-op branch Small, but it misdescribes the fallback contract my I first read this as a fallback-loss bug, and that reading is wrong; the counter-argument is worth recording: leadership movement does not remove the So the branch is fine; the example in the javadoc names a case that cannot reach it, which is misleading in a comment whose whole job is to explain when the grace-period fallback still applies. Suggest replacing "e.g. leadership moved" with the cases that actually reach it (topic deleted, service shutting down, or a consumer that never registered here). |
||
| * period covers that case. | ||
| */ | ||
| public CompletableFuture<Void> unregisterConsumer(TopicName topic, String subscription, | ||
| String consumerName) { | ||
| CompletableFuture<ScalableTopicController> future = controllers.get(topic.toString()); | ||
| if (future == null) { | ||
| return CompletableFuture.completedFuture(null); | ||
| } | ||
| return future.thenCompose(c -> c.unregisterConsumer(subscription, consumerName)); | ||
| } | ||
|
|
||
| // --- Internal helpers --- | ||
|
|
||
| private CompletableFuture<Void> createUnderlyingSegmentTopic(TopicName parentTopic, SegmentInfo segment) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -444,21 +444,6 @@ public void testConsumerRejoiningAfterLeaveDoesNotWedgeRelease() throws Exceptio | |
| // watermark) must not leak into the last flip's drain: with everything acked and nothing | ||
| // outstanding, a stale delivered-vs-acked pair would make the drain wait forever and the | ||
| // rejoining consumer could never attach. | ||
| // A consumer's clean close is a disconnect to the controller, which holds its session for | ||
| // the grace period before rebalancing. Shrink it (read at coordinator creation, i.e. on | ||
| // this subscription's first register) so B1's departure hands the segment back promptly. | ||
| int defaultGrace = getPulsar().getConfiguration() | ||
| .getScalableTopicConsumerSessionGracePeriodSeconds(); | ||
| getPulsar().getConfiguration().setScalableTopicConsumerSessionGracePeriodSeconds(1); | ||
| try { | ||
| runRejoinAfterLeaveScenario(); | ||
| } finally { | ||
| getPulsar().getConfiguration() | ||
| .setScalableTopicConsumerSessionGracePeriodSeconds(defaultGrace); | ||
| } | ||
| } | ||
|
|
||
| private void runRejoinAfterLeaveScenario() throws Exception { | ||
| String topic = newScalableTopic(1); | ||
| admin.scalableTopics().setAutoScalePolicy(topic, | ||
| AutoScalePolicyOverride.builder().enabled(false).build()); | ||
|
|
@@ -497,12 +482,12 @@ private void runRejoinAfterLeaveScenario() throws Exception { | |
| } | ||
| a.acknowledgeCumulative(last.id()); | ||
|
|
||
| // Phase 2 — B1 joins (shared, individual acks), both drain, then B1 leaves. B1 gets its | ||
| // own client: a departure is only visible to the controller as a connection drop, so | ||
| // leaving means closing the whole client (the shared client's pooled connection would | ||
| // keep B1's registration alive indefinitely). | ||
| PulsarClient b1Client = newV5Client(); | ||
| StreamConsumer<String> b1 = b1Client.newStreamConsumer(Schema.string()) | ||
| // Phase 2 — B1 joins (shared, individual acks), both drain, then B1 leaves. B1 uses the | ||
| // shared client on purpose: its close must reach the controller through the explicit | ||
| // unsubscribe (the pooled controller connection stays open, so without it the | ||
| // registration would linger for the full disconnect grace period and the segment | ||
| // would never be handed back within this test's window). | ||
| StreamConsumer<String> b1 = v5Client.newStreamConsumer(Schema.string()) | ||
| .topic(topic) | ||
| .subscriptionName(subscription) | ||
| .subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST) | ||
|
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] b1 leaks onto the shared client if an assertion fails before its close To be clear about what that test does earn: it is a real pin, not a weakened one. The default is one pooled connection per broker and consumer A stays attached, so B1's close cannot make the controller connection inactive and no grace timer can start — the 15-second What is left uncovered, all of it behaviour this PR's description advertises:
Separately, a small hygiene point on these lines:
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] nothing tests the new command directly, and b1 leaks onto the shared client if an assertion fails before its close
To be clear about what that test does earn: it is a real pin, not a weakened one. The default is one pooled connection per broker and consumer A stays attached, so B1's close cannot make the controller connection inactive and no grace timer can start — the 15-second What is left uncovered, all of it behaviour this PR's description advertises:
Separately, a small hygiene point on these lines: |
||
|
|
@@ -515,9 +500,9 @@ private void runRejoinAfterLeaveScenario() throws Exception { | |
| tb1.join(); | ||
| assertFalse(b1Got.isEmpty(), "consumer B1 received nothing — the segment did not fan out"); | ||
| b1.close(); | ||
| b1Client.close(); | ||
| // Wait until the controller has handed the whole segment back to A and A completed the | ||
| // flip back to Exclusive — the stale-watermark state only matters once that is done. | ||
| // The clean leave unregisters immediately (no grace wait): the controller hands the | ||
| // whole segment back to A, which flips back to Exclusive. Only once that is done does | ||
| // the stale-watermark state matter for B2's rejoin. | ||
| String segmentTopic = admin.scalableTopics().getStats(topic) | ||
| .getSegments().values().iterator().next().name(); | ||
| Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,8 @@ final class ScalableConsumerClient implements ScalableConsumerSession, AutoClose | |
| private volatile AssignmentChangeListener listener; | ||
| private volatile ClientCnx cnx; | ||
| private volatile boolean closed = false; | ||
| /** The in-flight (or last) subscribe attempt; close() awaits it before unsubscribing. */ | ||
| private volatile CompletableFuture<ScalableConsumerAssignment> lastSubscribeResult; | ||
|
|
||
| ScalableConsumerClient(PulsarClientImpl v4Client, | ||
| TopicName topicName, | ||
|
|
@@ -136,6 +138,11 @@ CompletableFuture<List<ActiveSegment>> start() { | |
| */ | ||
| private CompletableFuture<ScalableConsumerAssignment> connectAndSubscribe() { | ||
| CompletableFuture<ScalableConsumerAssignment> result = new CompletableFuture<>(); | ||
| // Published before the closed-flag check below: close() chains its clean unsubscribe on | ||
| // this attempt, so either the attempt observes closed and never subscribes, or close() | ||
| // observes the attempt and waits for its outcome — never an unsubscribe that overtakes | ||
| // the in-flight registration (which would no-op and leave a ghost registration behind). | ||
| lastSubscribeResult = result; | ||
|
|
||
| DagWatchClient watch = new DagWatchClient(v4Client, topicName); | ||
| watch.start() | ||
|
|
@@ -374,11 +381,47 @@ public void close() { | |
| return; | ||
| } | ||
| closed = true; | ||
| CompletableFuture<ScalableConsumerAssignment> pending = lastSubscribeResult; | ||
|
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] two concurrent reconnect attempts can overwrite lastSubscribeResult, so close() waits on one attempt while another registers afterwards
How two attempts arise.
Neither the backoff nor Failure scenario. Attempt A has already passed its The double-
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] two concurrent reconnect attempts can overwrite lastSubscribeResult, so close() waits on one attempt while another registers afterwards
How two attempts arise.
Neither the backoff nor Failure scenario. Attempt A has already passed its The double- |
||
| if (pending == null) { | ||
| // Never attempted to subscribe: nothing registered anywhere. | ||
| return; | ||
| } | ||
| // Clean leave: tell the controller to unregister this consumer and rebalance the group | ||
| // immediately, instead of holding the registration for the disconnect grace period (the | ||
| // controller connection is pooled, so closing this consumer does not close the channel | ||
| // and the broker would otherwise never notice the departure while the client lives). | ||
| // Chained on the in-flight subscribe outcome so the unsubscribe can never overtake the | ||
| // registration (the broker records it before sending the subscribe response). Sent even | ||
| // when the subscribe failed — the command is idempotent, and skipping on a razor-edge | ||
| // failure would risk leaving a registration behind. Best-effort throughout: on any | ||
| // failure the grace period remains the fallback for the eventual real disconnect. | ||
| pending.whenComplete((__, subscribeEx) -> sendUnsubscribe()); | ||
|
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] close() now defers removeScalableConsumerSession behind a subscribe future that has no timeout, so an unanswered subscribe retains the closed session for the life of the pooled connection Before this PR, That future has no timeout. The subscribe response future is put straight into the map at Failure scenario. A live pooled connection where the broker accepts the subscribe and never answers it (for example The local unregistration does not need to wait on anything — the subscribe response is routed through
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] close() now defers removeScalableConsumerSession behind a subscribe future that has no timeout, so an unanswered subscribe retains the closed session for the life of the pooled connection Before this PR, That future has no timeout. The subscribe response future is put straight into the map at Failure scenario. A live pooled connection where the broker accepts the subscribe and never answers it (for example The local unregistration does not need to wait on anything — the subscribe response is routed through |
||
| } | ||
|
|
||
| private void sendUnsubscribe() { | ||
| ClientCnx c = cnx; | ||
| if (c != null) { | ||
| c.removeScalableConsumerSession(consumerId); | ||
| // No close command for now — broker reaps registrations via grace timer on | ||
| // disconnect. A future refactor can add an explicit unsubscribe. | ||
| if (c == null) { | ||
| return; | ||
| } | ||
| c.removeScalableConsumerSession(consumerId); | ||
| try { | ||
| long requestId = v4Client.newRequestId(); | ||
| var responseFuture = new TimedCompletableFuture<Void>(); | ||
| c.getPendingRequests().put(requestId, responseFuture); | ||
| c.ctx().writeAndFlush(Commands.newScalableTopicUnsubscribe(requestId, consumerId)) | ||
| .addListener(writeFuture -> { | ||
| if (!writeFuture.isSuccess()) { | ||
| c.getPendingRequests().remove(requestId); | ||
| } | ||
| }); | ||
|
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 unsubscribe request bypasses ClientCnx's timeout bookkeeping and pins the pooled connection as non-idle; its write-failure path never completes the future it logs from The unsubscribe request duplicates
The pre-existing subscribe path in this class has the same shape, so a small
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 unsubscribe request bypasses ClientCnx's timeout bookkeeping and pins the pooled connection as non-idle; its write-failure path never completes the future it logs from The unsubscribe request duplicates
The pre-existing subscribe path in this class has the same shape, so a small |
||
| responseFuture.exceptionally(ex -> { | ||
| log.debug().exceptionMessage(ex) | ||
| .log("Clean unsubscribe failed; relying on the grace period"); | ||
| return null; | ||
| }); | ||
| } catch (Exception e) { | ||
| log.debug().exceptionMessage(e) | ||
| .log("Clean unsubscribe failed; relying on the grace period"); | ||
| } | ||
| } | ||
|
|
||
|
|
||
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.
[BUG] the grace-period fallback this comment promises is not preserved: a failed metadata delete leaves the group wedged with no timer and no rebalance
The comment on these lines states the invariant the whole error path rests on: "Keep the ref: the channelInactive sweep can still report the disconnect, so the grace-period fallback stays alive for a registration the explicit unregister failed to delete." It does not hold.
SubscriptionCoordinator.unregisterConsumer(SubscriptionCoordinator.java:220-236) drops the in-memory session and cancels the grace timer before the async metadata delete:If the delete fails, the future fails and
thenApply— the rebalance — never runs. The promised fallback is then a no-op:channelInactive→ScalableTopicService.onConsumerDisconnect→SubscriptionCoordinator.onConsumerDisconnect(:245-250) opens withsessions.get(consumerName); if (session == null || !session.isConnected()) return;and the session is already gone, so no grace timer is ever armed. There is no delete retry and no periodic registration reconciliation;restoreConsumersonly runs on controller initialization / leader recovery.Failure scenario. A metadata-store error (session loss, store unavailable) during an ordinary clean consumer close. Afterwards: the in-memory session is gone,
segmentAssignmentsstill points at it because no rebalance ran, the persisted registration survives, the grace timer is cancelled, and nothing retries — the departed consumer's segment is never handed back to the group. That is exactly the wedge this PR exists to remove, now reachable on the common close path (before this PR, the explicit unregister was not on that path).Two related notes:
evictExpiredConsumer(SubscriptionCoordinator.java:~505-531) handles the identical failure better —.exceptionally(… return null).thenRun(… rebalanceAndNotify …)— so it still rebalances. The same operation now has two different failure behaviours, and the newly-common path has the worse one.consumer_idstill succeeds") makes this stickier rather than safer: once the first delete has failed, the session is absent, so any repeat call returns success immediately at:222-224without retrying the deletion — andServerCnxwould then drop its retained ref, removing even the nominal fallback.Moving
sessions.remove/cancelGraceTimerafter a successful delete inSubscriptionCoordinator.unregisterConsumer, or mirroring the eviction path's.exceptionally(...).thenRun(rebalance), would make the comment true.