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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new d1701ee6e0 [Feature][Connector-V2] Support Pulsar sink multi-table 
writes (#10670)
d1701ee6e0 is described below

commit d1701ee6e085c7cd1de00dda7efe29ac481d1511
Author: M MUKTHANANDA REDDDY <[email protected]>
AuthorDate: Fri Jun 19 19:21:19 2026 +0530

    [Feature][Connector-V2] Support Pulsar sink multi-table writes (#10670)
---
 docs/en/connectors/sink/Pulsar.md                  |  25 ++-
 .../seatunnel/pulsar/sink/PulsarSink.java          |  13 +-
 .../seatunnel/pulsar/sink/PulsarSinkFactory.java   |  20 +-
 .../seatunnel/pulsar/sink/PulsarSinkWriter.java    | 240 +++++++++++++++++----
 .../pulsar/sink/PulsarSinkFactoryTest.java         |  91 ++++++++
 .../pulsar/sink/PulsarSinkWriterTest.java          | 240 +++++++++++++++++++++
 6 files changed, 578 insertions(+), 51 deletions(-)

diff --git a/docs/en/connectors/sink/Pulsar.md 
b/docs/en/connectors/sink/Pulsar.md
index e67086c693..924007edfe 100644
--- a/docs/en/connectors/sink/Pulsar.md
+++ b/docs/en/connectors/sink/Pulsar.md
@@ -28,7 +28,7 @@ Sink connector for Apache Pulsar.
 
 |         Name         |  Type  | Required |       Default       |             
                                      Description                               
                     |
 
|----------------------|--------|----------|---------------------|------------------------------------------------------------------------------------------------------------------|
-| topic                | String | Yes      | -                   | sink pulsar 
topic                                                                           
                     |
+| topic                | String | No       | -                   | Sink Pulsar 
topic. Required for single-table writes and optional when records provide 
`SeaTunnelRow.tableId`.   |
 | client.service-url   | String | Yes      | -                   | Service URL 
provider for Pulsar service.                                                    
                     |
 | admin.service-url    | String | Yes      | -                   | The Pulsar 
service HTTP URL for the admin endpoint.                                        
                      |
 | auth.plugin-class    | String | No       | -                   | Name of the 
authentication plugin.                                                          
                     |
@@ -77,6 +77,15 @@ If you customize the delimiter, add the "field_delimiter" 
option.
 
 Customize the field delimiter for data format.The default field_delimiter is 
','.
 
+### topic [String]
+
+The default Pulsar topic used by the sink.
+
+For single-table pipelines, this option is required.
+For multi-table pipelines, the sink will use `SeaTunnelRow.getTableId()` as 
the target topic when it is present, and fall back to `topic` only when the row 
does not carry a table id.
+
+If neither `SeaTunnelRow.getTableId()` nor `topic` is available, the sink 
fails fast with a configuration error.
+
 ### semantics [Enum]
 
 Consistency semantics for writing to pulsar.
@@ -171,6 +180,20 @@ sink {
 }
 ```
 
+### Multi-table
+
+> This example routes each row to the Pulsar topic carried in 
`SeaTunnelRow.tableId`. In this mode, `topic` can be omitted.
+
+```hocon
+sink {
+  Pulsar {
+    client.service-url = "pulsar://localhost:6650"
+    admin.service-url = "http://localhost:8080";
+    plugin_output = "test"
+  }
+}
+```
+
 ## Changelog
 
 <ChangeLog />
diff --git 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSink.java
 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSink.java
index ef5aeb0c57..0826feefca 100644
--- 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSink.java
+++ 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSink.java
@@ -23,6 +23,7 @@ import org.apache.seatunnel.api.serialization.Serializer;
 import org.apache.seatunnel.api.sink.SeaTunnelSink;
 import org.apache.seatunnel.api.sink.SinkCommitter;
 import org.apache.seatunnel.api.sink.SinkWriter;
+import org.apache.seatunnel.api.sink.SupportMultiTableSink;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
@@ -42,7 +43,11 @@ import java.util.Optional;
  */
 public class PulsarSink
         implements SeaTunnelSink<
-                SeaTunnelRow, PulsarSinkState, PulsarCommitInfo, 
PulsarAggregatedCommitInfo> {
+                        SeaTunnelRow,
+                        PulsarSinkState,
+                        PulsarCommitInfo,
+                        PulsarAggregatedCommitInfo>,
+                SupportMultiTableSink {
 
     private final SeaTunnelRowType seaTunnelRowType;
     private final PulsarClientConfig clientConfig;
@@ -68,7 +73,11 @@ public class PulsarSink
     public SinkWriter<SeaTunnelRow, PulsarCommitInfo, PulsarSinkState> 
createWriter(
             SinkWriter.Context context) {
         return new PulsarSinkWriter(
-                context, clientConfig, seaTunnelRowType, readonlyConfig, 
Collections.emptyList());
+                context,
+                clientConfig,
+                seaTunnelRowType,
+                readonlyConfig,
+                Collections.<PulsarSinkState>emptyList());
     }
 
     @Override
diff --git 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactory.java
 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactory.java
index 6317c55f76..5db5575c69 100644
--- 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactory.java
+++ 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactory.java
@@ -17,12 +17,15 @@
 
 package org.apache.seatunnel.connectors.seatunnel.pulsar.sink;
 
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.table.connector.TableSink;
 import org.apache.seatunnel.api.table.factory.Factory;
 import org.apache.seatunnel.api.table.factory.TableSinkFactory;
 import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
+import org.apache.seatunnel.common.exception.CommonErrorCode;
 import 
org.apache.seatunnel.connectors.seatunnel.pulsar.config.PulsarSinkOptions;
+import 
org.apache.seatunnel.connectors.seatunnel.pulsar.exception.PulsarConnectorException;
 
 import com.google.auto.service.AutoService;
 
@@ -36,11 +39,9 @@ public class PulsarSinkFactory implements TableSinkFactory {
     @Override
     public OptionRule optionRule() {
         return OptionRule.builder()
-                .required(
-                        PulsarSinkOptions.CLIENT_SERVICE_URL,
-                        PulsarSinkOptions.ADMIN_SERVICE_URL,
-                        PulsarSinkOptions.TOPIC)
+                .required(PulsarSinkOptions.CLIENT_SERVICE_URL, 
PulsarSinkOptions.ADMIN_SERVICE_URL)
                 .optional(
+                        PulsarSinkOptions.TOPIC,
                         PulsarSinkOptions.FORMAT,
                         PulsarSinkOptions.FIELD_DELIMITER,
                         PulsarSinkOptions.MESSAGE_ROUTING_MODE,
@@ -58,6 +59,17 @@ public class PulsarSinkFactory implements TableSinkFactory {
 
     @Override
     public TableSink createSink(TableSinkFactoryContext context) {
+        validateSingleTableTopic(context);
         return () -> new PulsarSink(context.getOptions(), 
context.getCatalogTable());
     }
+
+    private void validateSingleTableTopic(TableSinkFactoryContext context) {
+        ReadonlyConfig options = context.getOptions();
+        if (context.getCatalogTable() != null
+                && !options.getOptional(PulsarSinkOptions.TOPIC).isPresent()) {
+            throw new PulsarConnectorException(
+                    CommonErrorCode.ILLEGAL_ARGUMENT,
+                    "Topic must be configured for single-table Pulsar sink.");
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriter.java
 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriter.java
index 26f35fab99..22e3c69448 100644
--- 
a/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriter.java
+++ 
b/seatunnel-connectors-v2/connector-pulsar/src/main/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriter.java
@@ -21,7 +21,9 @@ import 
org.apache.seatunnel.shade.com.google.common.collect.Lists;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.serialization.SerializationSchema;
+import org.apache.seatunnel.api.sink.MultiTableResourceManager;
 import org.apache.seatunnel.api.sink.SinkWriter;
+import org.apache.seatunnel.api.sink.SupportMultiTableSinkWriter;
 import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
@@ -47,26 +49,42 @@ import org.apache.pulsar.client.api.PulsarClientException;
 import org.apache.pulsar.client.api.TypedMessageBuilder;
 import org.apache.pulsar.client.impl.transaction.TransactionImpl;
 
+import lombok.extern.slf4j.Slf4j;
+
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 
+@Slf4j
 public class PulsarSinkWriter
-        implements SinkWriter<SeaTunnelRow, PulsarCommitInfo, PulsarSinkState> 
{
+        implements SinkWriter<SeaTunnelRow, PulsarCommitInfo, PulsarSinkState>,
+                SupportMultiTableSinkWriter<Void> {
+
+    @FunctionalInterface
+    interface ProducerCreator {
+        Producer<byte[]> create(String topic) throws PulsarClientException;
+    }
 
-    private Producer<byte[]> producer;
+    private final Map<String, Producer<byte[]>> producerMap = new 
ConcurrentHashMap<>();
+    private final AtomicLong pendingMessages;
+    private final AtomicReference<Throwable> sendMessageException;
+    private final ReadonlyConfig pluginConfig;
+    private final MessageRoutingMode messageRoutingMode;
+    private final ProducerCreator producerCreator;
     private PulsarClient pulsarClient;
     private SerializationSchema serializationSchema;
     private SerializationSchema keySerializationSchema;
-    private TransactionImpl transaction;
+    private volatile TransactionImpl transaction;
     private int transactionTimeout;
     private PulsarSemantics pulsarSemantics;
-    private final AtomicLong pendingMessages;
 
     public PulsarSinkWriter(
             Context context,
@@ -74,51 +92,104 @@ public class PulsarSinkWriter
             SeaTunnelRowType seaTunnelRowType,
             ReadonlyConfig pluginConfig,
             List<PulsarSinkState> pulsarStates) {
-        String topic = pluginConfig.get(PulsarSinkOptions.TOPIC);
+        this(
+                seaTunnelRowType,
+                pluginConfig,
+                pulsarStates,
+                PulsarConfigUtil.createClient(
+                        clientConfig, 
pluginConfig.get(PulsarSinkOptions.SEMANTICS)),
+                null);
+    }
+
+    PulsarSinkWriter(
+            SeaTunnelRowType seaTunnelRowType,
+            ReadonlyConfig pluginConfig,
+            List<PulsarSinkState> pulsarStates,
+            PulsarClient pulsarClient,
+            ProducerCreator producerCreator) {
         String format = pluginConfig.get(PulsarSinkOptions.FORMAT);
         String delimiter = pluginConfig.get(PulsarSinkOptions.FIELD_DELIMITER);
         this.transactionTimeout = 
pluginConfig.get(PulsarSinkOptions.TRANSACTION_TIMEOUT);
         this.pulsarSemantics = pluginConfig.get(PulsarSinkOptions.SEMANTICS);
-        MessageRoutingMode messageRoutingMode =
-                pluginConfig.get(PulsarSinkOptions.MESSAGE_ROUTING_MODE);
+        this.messageRoutingMode = 
pluginConfig.get(PulsarSinkOptions.MESSAGE_ROUTING_MODE);
         this.serializationSchema = createSerializationSchema(seaTunnelRowType, 
format, delimiter);
         List<String> partitionKeyList = getPartitionKeyFields(pluginConfig, 
seaTunnelRowType);
         this.keySerializationSchema =
                 createKeySerializationSchema(partitionKeyList, 
seaTunnelRowType);
-        this.pulsarClient = PulsarConfigUtil.createClient(clientConfig, 
pulsarSemantics);
+        this.pulsarClient = pulsarClient;
+        this.pluginConfig = pluginConfig;
+        this.producerCreator =
+                producerCreator != null
+                        ? producerCreator
+                        : topic ->
+                                PulsarConfigUtil.createProducer(
+                                        this.pulsarClient,
+                                        topic,
+                                        pulsarSemantics,
+                                        pluginConfig,
+                                        messageRoutingMode);
+        this.pendingMessages = new AtomicLong(0);
+        this.sendMessageException = new AtomicReference<>();
 
         if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics) {
-            try {
-                this.transaction =
-                        (TransactionImpl)
-                                PulsarConfigUtil.getTransaction(pulsarClient, 
transactionTimeout);
-            } catch (Exception e) {
-                throw new PulsarConnectorException(
-                        PulsarConnectorErrorCode.CREATE_TRANSACTION_FAILED,
-                        "Pulsar transaction create fail.");
-            }
+            this.transaction = createTransaction();
+        }
+    }
+
+    String resolveTopic(SeaTunnelRow row) {
+        String tableId = row.getTableId();
+        if (tableId != null && !tableId.isEmpty()) {
+            return tableId;
         }
+
+        String topic = pluginConfig.get(PulsarSinkOptions.TOPIC);
+        if (topic == null || topic.isEmpty()) {
+            throw new PulsarConnectorException(
+                    CommonErrorCode.ILLEGAL_ARGUMENT,
+                    "Topic must be configured when SeaTunnelRow.getTableId() 
is null");
+        }
+
+        return topic;
+    }
+
+    Producer<byte[]> getOrCreateProducer(String topic) {
+        Producer<byte[]> existing = producerMap.get(topic);
+        if (existing != null) {
+            return existing;
+        }
+
         try {
-            this.producer =
-                    PulsarConfigUtil.createProducer(
-                            pulsarClient, topic, pulsarSemantics, 
pluginConfig, messageRoutingMode);
+            Producer<byte[]> producer = producerCreator.create(topic);
+
+            producerMap.put(topic, producer);
+            return producer;
+
         } catch (PulsarClientException e) {
             throw new PulsarConnectorException(
                     PulsarConnectorErrorCode.CREATE_PRODUCER_FAILED,
-                    "Pulsar Producer create fail.");
+                    "Failed to create Pulsar producer for topic: " + topic,
+                    e);
         }
-        this.pendingMessages = new AtomicLong(0);
     }
 
     @Override
     public void write(SeaTunnelRow element) throws IOException {
+        checkSendException();
+
+        String topic = resolveTopic(element);
         byte[] message = serializationSchema.serialize(element);
         byte[] key = null;
         if (keySerializationSchema != null) {
             key = keySerializationSchema.serialize(element);
         }
+
+        Producer<byte[]> topicProducer = getOrCreateProducer(topic);
+
         TypedMessageBuilder<byte[]> typedMessageBuilder =
-                PulsarConfigUtil.createTypedMessageBuilder(producer, 
transaction);
+                PulsarConfigUtil.createTypedMessageBuilder(
+                        topicProducer,
+                        PulsarSemantics.EXACTLY_ONCE == pulsarSemantics ? 
transaction : null);
+
         if (key != null) {
             typedMessageBuilder.keyBytes(key);
         }
@@ -132,9 +203,8 @@ public class PulsarSinkWriter
                     (id, ex) -> {
                         pendingMessages.decrementAndGet();
                         if (ex != null) {
-                            throw new PulsarConnectorException(
-                                    
PulsarConnectorErrorCode.SEND_MESSAGE_FAILED,
-                                    "send message failed");
+                            log.error("Failed to send message to topic {}", 
topic, ex);
+                            sendMessageException.compareAndSet(null, ex);
                         }
                     });
         }
@@ -142,7 +212,8 @@ public class PulsarSinkWriter
 
     @Override
     public Optional<PulsarCommitInfo> prepareCommit() throws IOException {
-        if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics) {
+        checkSendException();
+        if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics && transaction != 
null) {
             PulsarCommitInfo pulsarCommitInfo = new 
PulsarCommitInfo(this.transaction.getTxnID());
             return Optional.of(pulsarCommitInfo);
         } else {
@@ -153,40 +224,52 @@ public class PulsarSinkWriter
     @Override
     public List<PulsarSinkState> snapshotState(long checkpointId) throws 
IOException {
         if (PulsarSemantics.NON != pulsarSemantics) {
-            /** flush pending messages */
-            producer.flush();
-            while (pendingMessages.longValue() > 0) {
-                producer.flush();
-            }
+            flushPendingMessages();
+            checkSendException();
         }
         if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics) {
             List<PulsarSinkState> pulsarSinkStates =
                     Lists.newArrayList(new 
PulsarSinkState(this.transaction.getTxnID()));
-            try {
-                this.transaction =
-                        (TransactionImpl)
-                                PulsarConfigUtil.getTransaction(pulsarClient, 
transactionTimeout);
-            } catch (Exception e) {
-                throw new PulsarConnectorException(
-                        PulsarConnectorErrorCode.CREATE_TRANSACTION_FAILED,
-                        "Pulsar transaction create fail.");
-            }
+            this.transaction = createTransaction();
             return pulsarSinkStates;
         }
+
         return Collections.emptyList();
     }
 
     @Override
     public void abortPrepare() {
-        if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics) {
+        if (PulsarSemantics.EXACTLY_ONCE == pulsarSemantics && transaction != 
null) {
             transaction.abort();
         }
     }
 
     @Override
     public void close() throws IOException {
-        producer.close();
-        pulsarClient.close();
+        Throwable closeFailure = null;
+        for (Producer<byte[]> producer : producerMap.values()) {
+            try {
+                producer.close();
+            } catch (Throwable throwable) {
+                closeFailure = appendSuppressed(closeFailure, throwable);
+            }
+        }
+        if (pulsarClient != null) {
+            try {
+                pulsarClient.close();
+            } catch (Throwable throwable) {
+                closeFailure = appendSuppressed(closeFailure, throwable);
+            }
+        }
+
+        Throwable sendFailure = sendMessageException.get();
+        if (sendFailure != null) {
+            closeFailure = appendSuppressed(closeFailure, 
buildSendFailureException(sendFailure));
+        }
+
+        if (closeFailure != null) {
+            rethrowCloseFailure(closeFailure);
+        }
     }
 
     private SerializationSchema createSerializationSchema(
@@ -251,4 +334,73 @@ public class PulsarSinkWriter
         }
         return Collections.emptyList();
     }
+
+    private TransactionImpl createTransaction() {
+        try {
+            return (TransactionImpl)
+                    PulsarConfigUtil.getTransaction(pulsarClient, 
transactionTimeout);
+        } catch (Exception e) {
+            throw new PulsarConnectorException(
+                    PulsarConnectorErrorCode.CREATE_TRANSACTION_FAILED,
+                    "Pulsar transaction create fail.",
+                    e);
+        }
+    }
+
+    private void flushPendingMessages() throws IOException {
+        for (Producer<byte[]> producer : producerMap.values()) {
+            producer.flush();
+        }
+
+        while (pendingMessages.longValue() > 0) {
+            checkSendException();
+            for (Producer<byte[]> producer : producerMap.values()) {
+                producer.flush();
+            }
+        }
+    }
+
+    private void checkSendException() {
+        Throwable throwable = sendMessageException.get();
+        if (throwable != null) {
+            throw buildSendFailureException(throwable);
+        }
+    }
+
+    private PulsarConnectorException buildSendFailureException(Throwable 
throwable) {
+        return new PulsarConnectorException(
+                PulsarConnectorErrorCode.SEND_MESSAGE_FAILED,
+                "Send message failed, please check previous error log for 
details.",
+                throwable);
+    }
+
+    private Throwable appendSuppressed(Throwable existingFailure, Throwable 
newFailure) {
+        if (existingFailure == null) {
+            return newFailure;
+        }
+        existingFailure.addSuppressed(newFailure);
+        return existingFailure;
+    }
+
+    private void rethrowCloseFailure(Throwable throwable) throws IOException {
+        if (throwable instanceof IOException) {
+            throw (IOException) throwable;
+        }
+        if (throwable instanceof RuntimeException) {
+            throw (RuntimeException) throwable;
+        }
+        throw new IOException("Failed to close Pulsar sink writer.", 
throwable);
+    }
+
+    @Override
+    public MultiTableResourceManager<Void> initMultiTableResourceManager(
+            int tableSize, int queueSize) {
+        return null;
+    }
+
+    @Override
+    public void setMultiTableResourceManager(
+            MultiTableResourceManager<Void> multiTableResourceManager, int 
queueIndex) {
+        // Pulsar sink does not require shared resources across tables
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactoryTest.java
 
b/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactoryTest.java
new file mode 100644
index 0000000000..69ba2888f2
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkFactoryTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.pulsar.sink;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.Column;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
+import org.apache.seatunnel.api.table.type.BasicType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PulsarSinkFactoryTest {
+
+    @Test
+    public void testCreateSinkRequiresTopicForSingleTable() {
+        PulsarSinkFactory factory = new PulsarSinkFactory();
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        factory.createSink(
+                                new TableSinkFactoryContext(
+                                        getCatalogTable(), config(), 
getClass().getClassLoader())));
+    }
+
+    @Test
+    public void testCreateSinkAllowsMissingTopicForMultiTable() {
+        PulsarSinkFactory factory = new PulsarSinkFactory();
+
+        assertDoesNotThrow(
+                () ->
+                        factory.createSink(
+                                new TableSinkFactoryContext(
+                                        null, config(), 
getClass().getClassLoader())));
+    }
+
+    private ReadonlyConfig config() {
+        Map<String, Object> options = new HashMap<>();
+        options.put("client.service-url", "pulsar://localhost:6650");
+        options.put("admin.service-url", "http://localhost:8080";);
+        options.put("format", "json");
+        options.put("field_delimiter", ",");
+        options.put("semantics", "NON");
+        options.put("message.routing.mode", "RoundRobinPartition");
+        return ReadonlyConfig.fromMap(options);
+    }
+
+    private CatalogTable getCatalogTable() {
+        List<Column> columns = new ArrayList<>();
+        columns.add(
+                PhysicalColumn.builder()
+                        .name("id")
+                        .dataType(BasicType.INT_TYPE)
+                        .nullable(true)
+                        .build());
+        TableSchema tableSchema = 
TableSchema.builder().columns(columns).build();
+        return CatalogTable.of(
+                TableIdentifier.of("default", "default", "pulsar_table"),
+                tableSchema,
+                new HashMap<>(),
+                new ArrayList<>(),
+                "Pulsar table");
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriterTest.java
 
b/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriterTest.java
new file mode 100644
index 0000000000..e26dd9b6d0
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-pulsar/src/test/java/org/apache/seatunnel/connectors/seatunnel/pulsar/sink/PulsarSinkWriterTest.java
@@ -0,0 +1,240 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.pulsar.sink;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import 
org.apache.seatunnel.connectors.seatunnel.pulsar.exception.PulsarConnectorException;
+
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.PulsarClient;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Proxy;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class PulsarSinkWriterTest {
+
+    private PulsarSinkWriter createWriter(ReadonlyConfig config) {
+        return new PulsarSinkWriter(
+                new SeaTunnelRowType(new String[] {}, new SeaTunnelDataType[] 
{}),
+                config,
+                java.util.Collections.emptyList(),
+                null,
+                topic -> createProducerProxy());
+    }
+
+    @Test
+    public void testResolveTopicWithTableId() {
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {});
+        row.setTableId("persistent://tenant/ns/topic1");
+
+        Map<String, Object> configMap = new HashMap<>();
+        configMap.put("topic", "fallback-topic");
+        configMap.put("format", "json");
+        configMap.put("field_delimiter", ",");
+        configMap.put("transaction_timeout", 1000);
+        configMap.put("semantics", "NON");
+        configMap.put("message.routing.mode", "RoundRobinPartition");
+
+        ReadonlyConfig config = ReadonlyConfig.fromMap(configMap);
+
+        PulsarSinkWriter writer = createWriter(config);
+
+        String topic = writer.resolveTopic(row);
+
+        assertEquals("persistent://tenant/ns/topic1", topic);
+    }
+
+    @Test
+    public void testResolveTopicWithoutTableId() {
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {});
+        // no tableId set
+
+        Map<String, Object> configMap = new HashMap<>();
+        configMap.put("topic", "fallback-topic");
+        configMap.put("format", "json");
+        configMap.put("field_delimiter", ",");
+        configMap.put("transaction_timeout", 1000);
+        configMap.put("semantics", "NON");
+        configMap.put("message.routing.mode", "RoundRobinPartition");
+
+        ReadonlyConfig config = ReadonlyConfig.fromMap(configMap);
+
+        PulsarSinkWriter writer = createWriter(config);
+
+        String topic = writer.resolveTopic(row);
+
+        assertEquals("fallback-topic", topic);
+    }
+
+    @Test
+    public void testResolveTopicWithoutTableIdAndWithoutTopic() {
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {});
+
+        Map<String, Object> configMap = new HashMap<>();
+        configMap.put("format", "json");
+        configMap.put("field_delimiter", ",");
+        configMap.put("transaction_timeout", 1000);
+        configMap.put("semantics", "NON");
+        configMap.put("message.routing.mode", "RoundRobinPartition");
+
+        ReadonlyConfig config = ReadonlyConfig.fromMap(configMap);
+
+        PulsarSinkWriter writer = createWriter(config);
+
+        assertThrows(IllegalArgumentException.class, () -> 
writer.resolveTopic(row));
+    }
+
+    @Test
+    public void testGetOrCreateProducerCachesProducerByTopic() {
+        Map<String, Object> configMap = new HashMap<>();
+        configMap.put("format", "json");
+        configMap.put("field_delimiter", ",");
+        configMap.put("transaction_timeout", 1000);
+        configMap.put("semantics", "NON");
+        configMap.put("message.routing.mode", "RoundRobinPartition");
+
+        AtomicInteger createCount = new AtomicInteger();
+        PulsarSinkWriter writer =
+                new PulsarSinkWriter(
+                        new SeaTunnelRowType(new String[] {}, new 
SeaTunnelDataType[] {}),
+                        ReadonlyConfig.fromMap(configMap),
+                        java.util.Collections.emptyList(),
+                        null,
+                        topic -> {
+                            createCount.incrementAndGet();
+                            return createProducerProxy();
+                        });
+
+        Producer<byte[]> first = writer.getOrCreateProducer("topic-a");
+        Producer<byte[]> second = writer.getOrCreateProducer("topic-a");
+        Producer<byte[]> third = writer.getOrCreateProducer("topic-b");
+
+        assertSame(first, second);
+        assertTrue(first != third);
+        assertEquals(2, createCount.get());
+    }
+
+    @Test
+    public void testCloseReleasesResourcesWhenAsyncSendFailed() throws 
Exception {
+        Map<String, Object> configMap = new HashMap<>();
+        configMap.put("format", "json");
+        configMap.put("field_delimiter", ",");
+        configMap.put("transaction_timeout", 1000);
+        configMap.put("semantics", "NON");
+        configMap.put("message.routing.mode", "RoundRobinPartition");
+
+        AtomicBoolean producerClosed = new AtomicBoolean(false);
+        AtomicBoolean clientClosed = new AtomicBoolean(false);
+
+        PulsarSinkWriter writer =
+                new PulsarSinkWriter(
+                        new SeaTunnelRowType(new String[] {}, new 
SeaTunnelDataType[] {}),
+                        ReadonlyConfig.fromMap(configMap),
+                        java.util.Collections.emptyList(),
+                        createPulsarClientProxy(clientClosed),
+                        topic -> createProducerProxy(producerClosed));
+        writer.getOrCreateProducer("topic-a");
+
+        Field sendFailureField = 
PulsarSinkWriter.class.getDeclaredField("sendMessageException");
+        sendFailureField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        AtomicReference<Throwable> sendFailure =
+                (AtomicReference<Throwable>) sendFailureField.get(writer);
+        sendFailure.set(new RuntimeException("send failed"));
+
+        assertThrows(PulsarConnectorException.class, writer::close);
+        assertTrue(producerClosed.get());
+        assertTrue(clientClosed.get());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Producer<byte[]> createProducerProxy() {
+        return createProducerProxy(new AtomicBoolean(false));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Producer<byte[]> createProducerProxy(AtomicBoolean closed) {
+        return (Producer<byte[]>)
+                Proxy.newProxyInstance(
+                        Producer.class.getClassLoader(),
+                        new Class[] {Producer.class},
+                        (proxy, method, args) -> {
+                            if ("close".equals(method.getName())) {
+                                closed.set(true);
+                                return null;
+                            }
+                            Class<?> returnType = method.getReturnType();
+                            if (returnType.equals(boolean.class)) {
+                                return false;
+                            }
+                            if (returnType.equals(int.class)) {
+                                return 0;
+                            }
+                            if (returnType.equals(long.class)) {
+                                return 0L;
+                            }
+                            if (returnType.equals(float.class)) {
+                                return 0F;
+                            }
+                            if (returnType.equals(double.class)) {
+                                return 0D;
+                            }
+                            return null;
+                        });
+    }
+
+    @SuppressWarnings("unchecked")
+    private static PulsarClient createPulsarClientProxy(AtomicBoolean closed) {
+        return (PulsarClient)
+                Proxy.newProxyInstance(
+                        PulsarClient.class.getClassLoader(),
+                        new Class[] {PulsarClient.class},
+                        (proxy, method, args) -> {
+                            if ("close".equals(method.getName())) {
+                                closed.set(true);
+                                return null;
+                            }
+                            Class<?> returnType = method.getReturnType();
+                            if (returnType.equals(boolean.class)) {
+                                return false;
+                            }
+                            if (returnType.equals(int.class)) {
+                                return 0;
+                            }
+                            if (returnType.equals(long.class)) {
+                                return 0L;
+                            }
+                            return null;
+                        });
+    }
+}


Reply via email to