You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by mm...@apache.org on 2026/09/22 22:08:47 UTC

(pulsar) branch master updated: [fix][client] Complete a v5 send in place while its client is closing (#26686)

This is an automated email from the ASF dual-hosted git repository.

merlimat pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git


The following commit(s) were added to refs/heads/master by this push:
     new 8d90feaddb52 [fix][client] Complete a v5 send in place while its client is closing (#26686)
8d90feaddb52 is described below

commit 8d90feaddb529533c97b9081381919cdd12c7ee7
Author: SeungTaek Lim <na...@gmail.com>
AuthorDate: Wed Sep 23 07:08:39 2026 +0900

    [fix][client] Complete a v5 send in place while its client is closing (#26686)
---
 .../impl/v5/V5ProducerClosingCompletionTest.java   | 106 +++++++++++++++++++++
 .../client/impl/v5/ScalableTopicProducer.java      |   6 +-
 2 files changed, 111 insertions(+), 1 deletion(-)

diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/v5/V5ProducerClosingCompletionTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/v5/V5ProducerClosingCompletionTest.java
new file mode 100644
index 000000000000..31b12d450cfe
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/v5/V5ProducerClosingCompletionTest.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.client.impl.v5;
+
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.client.api.v5.MessageId;
+import org.apache.pulsar.client.api.v5.Producer;
+import org.apache.pulsar.client.api.v5.PulsarClient;
+import org.apache.pulsar.client.api.v5.PulsarClientException;
+import org.apache.pulsar.client.api.v5.V5ClientBaseTest;
+import org.apache.pulsar.client.api.v5.config.BatchingPolicy;
+import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
+import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.awaitility.Awaitility;
+import org.testng.annotations.Test;
+
+/**
+ * A send that fails because its client is closing must still complete its future. The failure is
+ * delivered on the client's completion executor, which the client shuts down with
+ * {@code shutdownNow()}, dropping whatever is still queued on it at that moment.
+ */
+public class V5ProducerClosingCompletionTest extends V5ClientBaseTest {
+
+    /** One callback thread, so that holding it holds the producer's only completion executor. */
+    private PulsarClient newClientWithOneCallbackThread() throws Exception {
+        return track(PulsarClient.builder()
+                .serviceUrl(getBrokerServiceUrl())
+                .connectionPolicy(ConnectionPolicy.builder().ioThreads(1).callbackThreads(1).build())
+                .build());
+    }
+
+    private static void awaitQuietly(CountDownLatch gate) {
+        try {
+            gate.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    @Test
+    public void aSendFailedWhileTheClientIsClosingCompletesEvenIfItsCompletionWasQueued() throws Exception {
+        PulsarClient client = newClientWithOneCallbackThread();
+        PulsarClientImpl v4Client = ((PulsarClientV5) client).v4Client();
+        String topic = newScalableTopic(1);
+        Producer<byte[]> producer = track(client.newProducer(Schema.bytes())
+                .topic(topic)
+                .batchingPolicy(BatchingPolicy.ofDisabled())
+                .create());
+        // The per-segment producer exists, so a later send has nothing to create.
+        producer.newMessage().value(new byte[16]).send();
+
+        // Hold the only completion executor: a completion handed to it now stays queued behind this.
+        CountDownLatch gate = new CountDownLatch(1);
+        v4Client.externalExecutorProvider().getExecutor().execute(() -> awaitQuietly(gate));
+        try {
+            // Close on another thread: close() returns only once the client's executors are gone.
+            CompletableFuture<Void> closing = CompletableFuture.runAsync(() -> {
+                try {
+                    client.close();
+                } catch (Exception e) {
+                    throw new CompletionException(e);
+                }
+            });
+            Awaitility.await().pollDelay(Duration.ZERO).pollInterval(Duration.ofMillis(1))
+                    .until(v4Client::isClosed);
+
+            // Fails right away, the client is closing; its completion is handed to the held executor.
+            CompletableFuture<MessageId> send = producer.async().newMessage().value(new byte[16]).send();
+
+            // shutdownNow(): the held task is interrupted and whatever was queued behind it is dropped.
+            closing.get(30, TimeUnit.SECONDS);
+            gate.countDown();
+
+            ExecutionException failure = expectThrows(ExecutionException.class,
+                    () -> send.get(5, TimeUnit.SECONDS));
+            assertTrue(failure.getCause() instanceof PulsarClientException.AlreadyClosedException,
+                    "the send must fail because the client closed, got " + failure.getCause());
+        } finally {
+            gate.countDown();
+        }
+    }
+}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
index 350e2191b5e3..fab665c65d87 100644
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
@@ -676,7 +676,11 @@ final class ScalableTopicProducer<T> implements Producer<T>, DagWatchClient.Layo
             return;
         }
         boolean offload = send.dispatching || (failure != null && !isEventLoopThread());
-        if (!offload) {
+        if (!offload || client.v4Client().isClosed()) {
+            // In place: the result arrived on an IO thread, or the client is closing. Its executors
+            // are shut down with it, with shutdownNow(), which drops whatever is still queued: a
+            // completion handed to the executor now could be lost and the caller's future never
+            // complete. Once the executors are gone, the same happens below.
             complete(send, messageId, failure);
             return;
         }