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

ferenc-csaky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-pulsar.git


The following commit(s) were added to refs/heads/main by this push:
     new e26b600  [FLINK-41009] Use a single transaction per checkpoint
e26b600 is described below

commit e26b60037d59474fa7c15b7783953a2a01654d54
Author: fengyubiao <[email protected]>
AuthorDate: Sun Oct 25 14:04:36 2026 +0800

    [FLINK-41009] Use a single transaction per checkpoint
---
 .../pulsar/sink/committer/MessageIdPojo.java       | 119 ++++++++++++++++
 .../pulsar/sink/committer/PulsarCommittable.java   |  29 ++--
 .../committer/PulsarCommittableSerializer.java     |  73 ++++++++--
 .../pulsar/sink/committer/PulsarCommitter.java     |   7 +-
 .../connector/pulsar/sink/writer/PulsarWriter.java |  67 +++++++--
 .../pulsar/sink/writer/topic/ProducerRegister.java |  63 ++++-----
 .../committer/PulsarCommittableSerializerTest.java | 152 ++++++++++++++++++++-
 .../pulsar/sink/writer/PulsarWriterTest.java       |   1 +
 .../sink/writer/topic/ProducerRegisterTest.java    |  40 ++++--
 9 files changed, 461 insertions(+), 90 deletions(-)

diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/MessageIdPojo.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/MessageIdPojo.java
new file mode 100644
index 0000000..9984904
--- /dev/null
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/MessageIdPojo.java
@@ -0,0 +1,119 @@
+/*
+ * 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.flink.connector.pulsar.sink.committer;
+
+import org.apache.flink.annotation.Internal;
+
+import java.util.Objects;
+
+/** A POJO representation of Pulsar message ID used for serialization. */
+@Internal
+public class MessageIdPojo {
+
+    private int batchIndex;
+    private int batchSize;
+    private long ledgerId;
+    private long entryId;
+    private int partitionIndex;
+
+    public MessageIdPojo() {}
+
+    public MessageIdPojo(
+            long ledgerId, long entryId, int batchSize, int batchIndex, int 
partitionIndex) {
+        this.batchIndex = batchIndex;
+        this.batchSize = batchSize;
+        this.ledgerId = ledgerId;
+        this.entryId = entryId;
+        this.partitionIndex = partitionIndex;
+    }
+
+    public int getBatchIndex() {
+        return batchIndex;
+    }
+
+    public void setBatchIndex(int batchIndex) {
+        this.batchIndex = batchIndex;
+    }
+
+    public int getBatchSize() {
+        return batchSize;
+    }
+
+    public void setBatchSize(int batchSize) {
+        this.batchSize = batchSize;
+    }
+
+    public long getLedgerId() {
+        return ledgerId;
+    }
+
+    public void setLedgerId(long ledgerId) {
+        this.ledgerId = ledgerId;
+    }
+
+    public long getEntryId() {
+        return entryId;
+    }
+
+    public void setEntryId(long entryId) {
+        this.entryId = entryId;
+    }
+
+    public int getPartitionIndex() {
+        return partitionIndex;
+    }
+
+    public void setPartitionIndex(int partitionIndex) {
+        this.partitionIndex = partitionIndex;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof MessageIdPojo)) {
+            return false;
+        }
+        MessageIdPojo that = (MessageIdPojo) o;
+        return batchIndex == that.batchIndex
+                && batchSize == that.batchSize
+                && ledgerId == that.ledgerId
+                && entryId == that.entryId
+                && partitionIndex == that.partitionIndex;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(batchIndex, batchSize, ledgerId, entryId, 
partitionIndex);
+    }
+
+    @Override
+    public String toString() {
+        return "MessageIdPojo{"
+                + "batchIndex="
+                + batchIndex
+                + ", batchSize="
+                + batchSize
+                + ", ledgerId="
+                + ledgerId
+                + ", entryId="
+                + entryId
+                + ", partitionIndex="
+                + partitionIndex
+                + '}';
+    }
+}
diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittable.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittable.java
index cca8e80..7f33e75 100644
--- 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittable.java
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittable.java
@@ -22,6 +22,7 @@ import org.apache.flink.annotation.Internal;
 
 import org.apache.pulsar.client.api.transaction.TxnID;
 
+import java.util.Map;
 import java.util.Objects;
 
 /** The writer state for Pulsar connector. We would used in Pulsar committer. 
*/
@@ -31,41 +32,43 @@ public class PulsarCommittable {
     /** The transaction id. */
     private final TxnID txnID;
 
-    /** The topic name with partition information. */
-    private final String topic;
+    private final Map<String, MessageIdPojo> latestPublishedMessages;
 
-    public PulsarCommittable(TxnID txnID, String topic) {
+    public PulsarCommittable(TxnID txnID, Map<String, MessageIdPojo> 
latestPublishedMessages) {
         this.txnID = txnID;
-        this.topic = topic;
+        this.latestPublishedMessages = latestPublishedMessages;
     }
 
     public TxnID getTxnID() {
         return txnID;
     }
 
-    public String getTopic() {
-        return topic;
+    public Map<String, MessageIdPojo> getLatestPublishedMessages() {
+        return latestPublishedMessages;
     }
 
     @Override
     public boolean equals(Object o) {
-        if (this == o) {
-            return true;
-        }
-        if (o == null || getClass() != o.getClass()) {
+        if (!(o instanceof PulsarCommittable)) {
             return false;
         }
         PulsarCommittable that = (PulsarCommittable) o;
-        return Objects.equals(txnID, that.txnID) && Objects.equals(topic, 
that.topic);
+        return Objects.equals(txnID, that.txnID)
+                && Objects.equals(latestPublishedMessages, 
that.latestPublishedMessages);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(txnID, topic);
+        return Objects.hash(txnID, latestPublishedMessages);
     }
 
     @Override
     public String toString() {
-        return "PulsarCommittable{" + "txnID=" + txnID + ", topic='" + topic + 
'\'' + '}';
+        return "PulsarCommittable{"
+                + "txnID="
+                + txnID
+                + ", latestPublishedMessages="
+                + latestPublishedMessages
+                + '}';
     }
 }
diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializer.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializer.java
index 324a7c6..ed183c6 100644
--- 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializer.java
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializer.java
@@ -27,11 +27,20 @@ import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
-/** A serializer used to serialize {@link PulsarCommittable}. */
+/**
+ * A serializer used to serialize {@link PulsarCommittable}.
+ *
+ * <p>Each version only appends fields to the previous one, so connector 
versions that only know V1
+ * can still read V2 state.
+ */
 public class PulsarCommittableSerializer implements 
SimpleVersionedSerializer<PulsarCommittable> {
 
-    private static final int CURRENT_VERSION = 1;
+    private static final int CURRENT_VERSION = 2;
+    private static final String V2_LEGACY_TOPIC = 
"unknown_due_to_v2_serialization";
 
     @Override
     public int getVersion() {
@@ -40,26 +49,70 @@ public class PulsarCommittableSerializer implements 
SimpleVersionedSerializer<Pu
 
     @Override
     public byte[] serialize(PulsarCommittable obj) throws IOException {
-        try (final ByteArrayOutputStream baos = new ByteArrayOutputStream();
-                final DataOutputStream out = new DataOutputStream(baos)) {
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+                DataOutputStream output = new DataOutputStream(bytes)) {
             TxnID txnID = obj.getTxnID();
-            out.writeLong(txnID.getMostSigBits());
-            out.writeLong(txnID.getLeastSigBits());
-            out.writeUTF(obj.getTopic());
-            out.flush();
-            return baos.toByteArray();
+            output.writeLong(txnID.getMostSigBits());
+            output.writeLong(txnID.getLeastSigBits());
+            // V1 topic field. A transaction can now span topics, so use a 
descriptive placeholder.
+            output.writeUTF(V2_LEGACY_TOPIC);
+
+            Map<String, MessageIdPojo> messages = 
obj.getLatestPublishedMessages();
+            output.writeInt(messages.size());
+            for (Map.Entry<String, MessageIdPojo> entry : messages.entrySet()) 
{
+                MessageIdPojo messageId = entry.getValue();
+                output.writeUTF(entry.getKey());
+                output.writeLong(messageId.getLedgerId());
+                output.writeLong(messageId.getEntryId());
+                output.writeInt(messageId.getBatchSize());
+                output.writeInt(messageId.getBatchIndex());
+                output.writeInt(messageId.getPartitionIndex());
+            }
+            output.flush();
+            return bytes.toByteArray();
         }
     }
 
     @Override
     public PulsarCommittable deserialize(int version, byte[] serialized) 
throws IOException {
+        if (version < 1 || version > CURRENT_VERSION) {
+            throw new IOException("Unsupported Pulsar committable version: " + 
version);
+        }
         try (final ByteArrayInputStream bais = new 
ByteArrayInputStream(serialized);
                 final DataInputStream in = new DataInputStream(bais)) {
             long mostSigBits = in.readLong();
             long leastSigBits = in.readLong();
             TxnID txnID = new TxnID(mostSigBits, leastSigBits);
+            // V1 topic field, unused since a transaction can span topics.
             String topic = in.readUTF();
-            return new PulsarCommittable(txnID, topic);
+            if (topic.isEmpty()) {
+                throw new IOException("Corrupt V1 committable: topic must not 
be empty.");
+            }
+
+            Map<String, MessageIdPojo> messages = Collections.emptyMap();
+            if (version >= CURRENT_VERSION) {
+                int messageCount = in.readInt();
+                if (messageCount < 0) {
+                    throw new IOException("Negative number of published 
messages: " + messageCount);
+                }
+
+                messages = new HashMap<>();
+                for (int i = 0; i < messageCount; i++) {
+                    String messageTopic = in.readUTF();
+                    MessageIdPojo messageId =
+                            new MessageIdPojo(
+                                    in.readLong(),
+                                    in.readLong(),
+                                    in.readInt(),
+                                    in.readInt(),
+                                    in.readInt());
+                    messages.put(messageTopic, messageId);
+                }
+            }
+            if (in.available() != 0) {
+                throw new IOException("Trailing bytes in Pulsar committable 
state");
+            }
+            return new PulsarCommittable(txnID, messages);
         }
     }
 }
diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommitter.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommitter.java
index 4b7506c..d3e6b63 100644
--- 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommitter.java
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommitter.java
@@ -75,9 +75,7 @@ public class PulsarCommitter implements 
Committer<PulsarCommittable>, Closeable
         for (CommitRequest<PulsarCommittable> request : requests) {
             PulsarCommittable committable = request.getCommittable();
             TxnID txnID = committable.getTxnID();
-            String topic = committable.getTopic();
-
-            LOG.debug("Start committing the Pulsar transaction {} for topic 
{}", txnID, topic);
+            LOG.debug("Start committing the Pulsar transaction {}", txnID);
             try {
                 client.commit(txnID);
             } catch (CoordinatorNotFoundException e) {
@@ -122,9 +120,8 @@ public class PulsarCommitter implements 
Committer<PulsarCommittable>, Closeable
                 request.signalFailedWithKnownReason(e);
             } catch (TransactionCoordinatorClientException e) {
                 LOG.error(
-                        "Encountered retriable exception while committing 
transaction {} for topic {}.",
+                        "Encountered retriable exception while committing 
transaction {}.",
                         committable,
-                        topic,
                         e);
                 int maxRecommitTimes = sinkConfiguration.getMaxRecommitTimes();
                 if (request.getNumberOfRetries() < maxRecommitTimes) {
diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriter.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriter.java
index 021a8f5..00e4245 100644
--- 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriter.java
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriter.java
@@ -26,6 +26,7 @@ import 
org.apache.flink.api.connector.sink2.CommittingSinkWriter;
 import org.apache.flink.api.connector.sink2.WriterInitContext;
 import org.apache.flink.connector.base.DeliveryGuarantee;
 import org.apache.flink.connector.pulsar.common.crypto.PulsarCrypto;
+import org.apache.flink.connector.pulsar.sink.committer.MessageIdPojo;
 import org.apache.flink.connector.pulsar.sink.committer.PulsarCommittable;
 import org.apache.flink.connector.pulsar.sink.config.SinkConfiguration;
 import org.apache.flink.connector.pulsar.sink.writer.context.PulsarSinkContext;
@@ -41,9 +42,11 @@ import org.apache.flink.metrics.groups.SinkWriterMetricGroup;
 import org.apache.flink.util.FlinkRuntimeException;
 
 import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.client.api.MessageIdAdv;
 import org.apache.pulsar.client.api.PulsarClientException;
 import org.apache.pulsar.client.api.Schema;
 import org.apache.pulsar.client.api.TypedMessageBuilder;
+import org.apache.pulsar.client.api.transaction.TxnID;
 import org.apache.pulsar.client.impl.TypedMessageBuilderImpl;
 import org.apache.pulsar.shade.com.google.common.base.Strings;
 import org.slf4j.Logger;
@@ -51,9 +54,12 @@ import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicLong;
 
 import static java.util.Collections.emptyList;
@@ -79,6 +85,7 @@ public class PulsarWriter<IN> implements 
CommittingSinkWriter<IN, PulsarCommitta
     private final ProducerRegister producerRegister;
     private final MailboxExecutor mailboxExecutor;
     private final AtomicLong pendingMessages;
+    private final ConcurrentHashMap<String, MessageIdAdv> 
latestPublishedMessages;
 
     /**
      * Constructor creating a Pulsar writer.
@@ -136,6 +143,10 @@ public class PulsarWriter<IN> implements 
CommittingSinkWriter<IN, PulsarCommitta
         this.producerRegister = new ProducerRegister(sinkConfiguration, 
pulsarCrypto, metricGroup);
         this.mailboxExecutor = initContext.getMailboxExecutor();
         this.pendingMessages = new AtomicLong(0);
+        this.latestPublishedMessages =
+                deliveryGuarantee == DeliveryGuarantee.EXACTLY_ONCE
+                        ? new ConcurrentHashMap<>()
+                        : null;
     }
 
     @Override
@@ -167,18 +178,38 @@ public class PulsarWriter<IN> implements 
CommittingSinkWriter<IN, PulsarCommitta
             CompletableFuture<MessageId> future = builder.sendAsync();
             future.whenComplete(
                     (id, ex) -> {
-                        pendingMessages.decrementAndGet();
-                        if (ex != null) {
-                            mailboxExecutor.execute(
-                                    () -> throwSendingException(topic, ex),
-                                    "Failed to send data to Pulsar");
-                        } else {
-                            LOG.debug("Sent message to Pulsar {} with message 
id {}", topic, id);
+                        try {
+                            if (ex != null) {
+                                mailboxExecutor.execute(
+                                        () -> throwSendingException(topic, ex),
+                                        "Failed to send data to Pulsar");
+                            } else {
+                                recordLatestMessageId(topic, id);
+                                LOG.debug(
+                                        "Sent message to Pulsar {} with 
message id {}", topic, id);
+                            }
+                        } finally {
+                            pendingMessages.decrementAndGet();
                         }
                     });
         }
     }
 
+    private void recordLatestMessageId(String topic, MessageId messageId) {
+        if (deliveryGuarantee != DeliveryGuarantee.EXACTLY_ONCE) {
+            return;
+        }
+
+        // Producer sends return MessageIdImpl subclasses, which implement 
MessageIdAdv.
+        MessageIdAdv messageIdAdv = (MessageIdAdv) messageId;
+        latestPublishedMessages.compute(
+                topic,
+                (ignored, current) ->
+                        current == null || messageIdAdv.compareTo(current) > 0
+                                ? messageIdAdv
+                                : current);
+    }
+
     private void throwSendingException(String topic, Throwable ex) {
         throw new FlinkRuntimeException("Failed to send data to Pulsar: " + 
topic, ex);
     }
@@ -266,7 +297,27 @@ public class PulsarWriter<IN> implements 
CommittingSinkWriter<IN, PulsarCommitta
     @Override
     public Collection<PulsarCommittable> prepareCommit() {
         if (deliveryGuarantee == DeliveryGuarantee.EXACTLY_ONCE) {
-            return producerRegister.prepareCommit();
+            TxnID txnID = producerRegister.prepareCommit();
+            if (txnID == null) {
+                return Collections.emptyList();
+            }
+
+            // Flink invokes flush() before prepareCommit(). flush() waits for 
all pending async
+            // sends and their callbacks, so no message ID can be added 
between copying and clearing
+            // the map below.
+            Map<String, MessageIdPojo> messageIds = new HashMap<>();
+            latestPublishedMessages.forEach(
+                    (topic, messageId) ->
+                            messageIds.put(
+                                    topic,
+                                    new MessageIdPojo(
+                                            messageId.getLedgerId(),
+                                            messageId.getEntryId(),
+                                            messageId.getBatchSize(),
+                                            messageId.getBatchIndex(),
+                                            messageId.getPartitionIndex())));
+            latestPublishedMessages.clear();
+            return Collections.singletonList(new PulsarCommittable(txnID, 
messageIds));
         } else {
             return emptyList();
         }
diff --git 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegister.java
 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegister.java
index b84d6d9..375f589 100644
--- 
a/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegister.java
+++ 
b/flink-connector-pulsar/src/main/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegister.java
@@ -23,7 +23,6 @@ import org.apache.flink.connector.base.DeliveryGuarantee;
 import org.apache.flink.connector.pulsar.common.crypto.PulsarCrypto;
 import 
org.apache.flink.connector.pulsar.common.metrics.ProducerMetricsInterceptor;
 import org.apache.flink.connector.pulsar.common.schema.PulsarSchemaUtils;
-import org.apache.flink.connector.pulsar.sink.committer.PulsarCommittable;
 import org.apache.flink.connector.pulsar.sink.config.SinkConfiguration;
 import org.apache.flink.metrics.MetricGroup;
 import org.apache.flink.metrics.groups.SinkWriterMetricGroup;
@@ -56,9 +55,7 @@ import javax.annotation.Nullable;
 
 import java.io.Closeable;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
@@ -104,7 +101,7 @@ public class ProducerRegister implements Closeable {
     private final SinkWriterMetricGroup metricGroup;
     private final Map<String, Schema<byte[]>> schemas;
     private final Map<String, Map<SchemaHash, Producer<?>>> producers;
-    private final Map<String, Transaction> transactions;
+    @Nullable private Transaction transaction;
 
     public ProducerRegister(
             SinkConfiguration sinkConfiguration,
@@ -117,7 +114,6 @@ public class ProducerRegister implements Closeable {
         this.metricGroup = metricGroup;
         this.schemas = new HashMap<>();
         this.producers = new HashMap<>();
-        this.transactions = new HashMap<>();
 
         if (sinkConfiguration.isEnableMetrics()) {
             metricGroup.setCurrentSendTimeGauge(this::currentSendTimeGauge);
@@ -133,9 +129,9 @@ public class ProducerRegister implements Closeable {
 
     /**
      * Create a TypedMessageBuilder which could be sent to Pulsar directly. 
First, we would create a
-     * topic-related producer or use a cached one instead. Then we would try 
to find a topic-related
-     * transaction. We would generate a transaction instance if there is no 
transaction. Finally, we
-     * created the message builder and put the element into it.
+     * topic-related producer or use a cached one instead. Then we would get 
the transaction for the
+     * current checkpoint, creating it if necessary. Finally, we create the 
message builder and put
+     * the element into it.
      *
      * <p>Pulsar's Producer doesn't have {@code producer.newMessage(schema, 
transaction)} method. We
      * have to manually create it.
@@ -150,29 +146,23 @@ public class ProducerRegister implements Closeable {
         TransactionImpl transaction = null;
 
         if (sinkConfiguration.getDeliveryGuarantee() == 
DeliveryGuarantee.EXACTLY_ONCE) {
-            transaction = (TransactionImpl) getOrCreateTransaction(topic);
+            transaction = (TransactionImpl) getOrCreateTransaction();
         }
 
         return (TypedMessageBuilder<T>)
                 new TypedMessageBuilderImpl<>(producer, schema, transaction);
     }
 
-    /**
-     * Convert the transactions into a committable list for Pulsar Committer. 
The transactions would
-     * be removed until Flink triggered a checkpoint.
-     */
-    public List<PulsarCommittable> prepareCommit() {
-        List<PulsarCommittable> committables = new 
ArrayList<>(transactions.size());
-        for (Map.Entry<String, Transaction> entry : transactions.entrySet()) {
-            String topic = entry.getKey();
-            Transaction transaction = entry.getValue();
-            TxnID txnID = transaction.getTxnID();
-
-            committables.add(new PulsarCommittable(txnID, topic));
+    /** Return the transaction for the current checkpoint and clear it from 
the writer state. */
+    @Nullable
+    public TxnID prepareCommit() {
+        if (transaction == null) {
+            return null;
         }
-        transactions.clear();
 
-        return committables;
+        TxnID txnID = transaction.getTxnID();
+        transaction = null;
+        return txnID;
     }
 
     /**
@@ -272,18 +262,14 @@ public class ProducerRegister implements Closeable {
         }
     }
 
-    /**
-     * Get the cached topic-related transaction. Or create a new transaction 
after checkpointing.
-     */
-    private Transaction getOrCreateTransaction(String topic) throws 
PulsarClientException {
-        if (transactions.containsKey(topic)) {
-            return transactions.get(topic);
+    /** Get the transaction for the current checkpoint, or create one after 
checkpointing. */
+    private Transaction getOrCreateTransaction() throws PulsarClientException {
+        if (transaction != null) {
+            return transaction;
         }
 
         long timeoutMillis = sinkConfiguration.getTransactionTimeoutMillis();
-        Transaction transaction = createTransaction(pulsarClient, 
timeoutMillis);
-        transactions.put(topic, transaction);
-
+        transaction = createTransaction(pulsarClient, timeoutMillis);
         return transaction;
     }
 
@@ -299,19 +285,16 @@ public class ProducerRegister implements Closeable {
         }
     }
 
-    /** Abort the existed transactions. This method would be used when closing 
PulsarWriter. */
+    /** Abort the outstanding transaction. This method is used when closing 
PulsarWriter. */
     private void abortTransactions() {
-        if (coordinatorClient == null || transactions.isEmpty()) {
+        if (coordinatorClient == null || transaction == null) {
             return;
         }
 
+        TxnID txnID = transaction.getTxnID();
         try (Closer closer = Closer.create()) {
-            for (Transaction transaction : transactions.values()) {
-                TxnID txnID = transaction.getTxnID();
-                closer.register(() -> coordinatorClient.abort(txnID));
-            }
-
-            transactions.clear();
+            closer.register(() -> coordinatorClient.abort(txnID));
+            transaction = null;
         } catch (IOException e) {
             throw new FlinkRuntimeException(e);
         }
diff --git 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializerTest.java
 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializerTest.java
index f13a4bf..a0fe60f 100644
--- 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializerTest.java
+++ 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/committer/PulsarCommittableSerializerTest.java
@@ -21,11 +21,19 @@ package org.apache.flink.connector.pulsar.sink.committer;
 import org.apache.pulsar.client.api.transaction.TxnID;
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
 
-import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Tests for serializing and deserializing {@link PulsarCommittable} with 
{@link
@@ -36,18 +44,154 @@ class PulsarCommittableSerializerTest {
     private static final PulsarCommittableSerializer INSTANCE = new 
PulsarCommittableSerializer();
 
     @Test
-    void committableSerDe() throws IOException {
-        String topic = randomAlphabetic(10);
+    void v2SerializeAndDeserializeWithMessages() throws IOException {
         TxnID txnID =
                 new TxnID(
                         ThreadLocalRandom.current().nextLong(),
                         ThreadLocalRandom.current().nextLong());
+        Map<String, MessageIdPojo> messages = new HashMap<>();
+        messages.put("topic-a", new MessageIdPojo(1L, 2L, 3, 0, -1));
+        messages.put("topic-b", new MessageIdPojo(10L, 20L, 1, 0, -1));
 
-        PulsarCommittable committable = new PulsarCommittable(txnID, topic);
+        PulsarCommittable committable = new PulsarCommittable(txnID, messages);
 
         byte[] bytes = INSTANCE.serialize(committable);
         PulsarCommittable committable1 = 
INSTANCE.deserialize(INSTANCE.getVersion(), bytes);
 
         assertThat(committable1).isEqualTo(committable);
     }
+
+    @Test
+    void v2SerializeAndDeserializeWithoutMessages() throws IOException {
+        PulsarCommittable committable =
+                new PulsarCommittable(new TxnID(1L, 2L), 
Collections.emptyMap());
+
+        byte[] bytes = INSTANCE.serialize(committable);
+        PulsarCommittable deserialized = 
INSTANCE.deserialize(INSTANCE.getVersion(), bytes);
+
+        assertThat(deserialized).isEqualTo(committable);
+        assertThat(deserialized.getLatestPublishedMessages()).isEmpty();
+    }
+
+    @Test
+    void v1DeserializationIsCompatible() throws IOException {
+        TxnID txnID =
+                new TxnID(
+                        ThreadLocalRandom.current().nextLong(),
+                        ThreadLocalRandom.current().nextLong());
+        byte[] serialized;
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+                DataOutputStream output = new DataOutputStream(bytes)) {
+            output.writeLong(txnID.getMostSigBits());
+            output.writeLong(txnID.getLeastSigBits());
+            output.writeUTF("Topic_Placeholder");
+            output.flush();
+            serialized = bytes.toByteArray();
+        }
+
+        PulsarCommittable committable = INSTANCE.deserialize(1, serialized);
+
+        assertThat(committable.getTxnID()).isEqualTo(txnID);
+        assertThat(committable.getLatestPublishedMessages()).isEmpty();
+
+        byte[] v2Serialized = INSTANCE.serialize(committable);
+        PulsarCommittable roundTripped = 
INSTANCE.deserialize(INSTANCE.getVersion(), v2Serialized);
+        assertThat(roundTripped).isEqualTo(committable);
+    }
+
+    @Test
+    void v2StateIsReadableByV1Deserializer() throws IOException {
+        TxnID txnID = new TxnID(1L, 2L);
+        byte[] serialized =
+                INSTANCE.serialize(
+                        new PulsarCommittable(
+                                txnID, Collections.singletonMap("topic", 
messageId())));
+
+        // Connector versions that only know V1 ignore the serializer version 
and trailing bytes.
+        try (DataInputStream input = new DataInputStream(new 
ByteArrayInputStream(serialized))) {
+            assertThat(new TxnID(input.readLong(), 
input.readLong())).isEqualTo(txnID);
+            
assertThat(input.readUTF()).isEqualTo("unknown_due_to_v2_serialization");
+        }
+    }
+
+    @Test
+    void unknownVersionFailsDeserialization() {
+        assertThatThrownBy(() -> INSTANCE.deserialize(3, new byte[0]))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unsupported Pulsar committable 
version");
+    }
+
+    @Test
+    void truncatedV1StateFailsDeserialization() {
+        assertThatThrownBy(() -> INSTANCE.deserialize(1, new byte[16]))
+                .isInstanceOf(IOException.class);
+    }
+
+    @Test
+    void emptyTopicInV1StateFailsDeserialization() throws IOException {
+        byte[] serialized;
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+                DataOutputStream output = new DataOutputStream(bytes)) {
+            output.writeLong(1L);
+            output.writeLong(2L);
+            output.writeUTF("");
+            output.flush();
+            serialized = bytes.toByteArray();
+        }
+
+        assertThatThrownBy(() -> INSTANCE.deserialize(1, serialized))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("topic must not be empty");
+    }
+
+    @Test
+    void negativeMessageCountInV2StateFailsDeserialization() throws 
IOException {
+        byte[] serialized;
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+                DataOutputStream output = new DataOutputStream(bytes)) {
+            output.writeLong(1L);
+            output.writeLong(2L);
+            output.writeUTF("topic");
+            output.writeInt(-1);
+            output.flush();
+            serialized = bytes.toByteArray();
+        }
+
+        assertThatThrownBy(() -> INSTANCE.deserialize(2, serialized))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Negative number of published messages");
+    }
+
+    @Test
+    void oversizedMessageCountInV2StateFailsWithoutPreallocating() throws 
IOException {
+        byte[] serialized;
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+                DataOutputStream output = new DataOutputStream(bytes)) {
+            output.writeLong(1L);
+            output.writeLong(2L);
+            output.writeUTF("topic");
+            output.writeInt(Integer.MAX_VALUE);
+            output.flush();
+            serialized = bytes.toByteArray();
+        }
+
+        assertThatThrownBy(() -> INSTANCE.deserialize(2, serialized))
+                .isInstanceOf(IOException.class);
+    }
+
+    @Test
+    void trailingBytesInV2StateFailDeserialization() throws IOException {
+        PulsarCommittable committable =
+                new PulsarCommittable(new TxnID(1L, 2L), 
Collections.emptyMap());
+        byte[] serialized = INSTANCE.serialize(committable);
+        byte[] serializedWithTrailingByte = Arrays.copyOf(serialized, 
serialized.length + 1);
+
+        assertThatThrownBy(() -> INSTANCE.deserialize(2, 
serializedWithTrailingByte))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Trailing bytes");
+    }
+
+    private static MessageIdPojo messageId() {
+        return new MessageIdPojo(1L, 2L, 3, 0, -1);
+    }
 }
diff --git 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriterTest.java
 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriterTest.java
index 31ffd1b..b72a115 100644
--- 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriterTest.java
+++ 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/PulsarWriterTest.java
@@ -127,6 +127,7 @@ class PulsarWriterTest extends PulsarTestSuiteBase {
             assertThat(committables).hasSize(1);
             PulsarCommittable committable =
                     
committables.stream().findFirst().orElseThrow(IllegalArgumentException::new);
+            assertThat(committable.getLatestPublishedMessages()).hasSize(1);
             TransactionCoordinatorClient coordinatorClient = 
operator().coordinatorClient();
             coordinatorClient.commit(committable.getTxnID());
         }
diff --git 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegisterTest.java
 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegisterTest.java
index 03fef2b..2e82606 100644
--- 
a/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegisterTest.java
+++ 
b/flink-connector-pulsar/src/test/java/org/apache/flink/connector/pulsar/sink/writer/topic/ProducerRegisterTest.java
@@ -21,7 +21,6 @@ package org.apache.flink.connector.pulsar.sink.writer.topic;
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.connector.base.DeliveryGuarantee;
 import org.apache.flink.connector.pulsar.common.crypto.PulsarCrypto;
-import org.apache.flink.connector.pulsar.sink.committer.PulsarCommittable;
 import org.apache.flink.connector.pulsar.sink.config.SinkConfiguration;
 import org.apache.flink.connector.pulsar.testutils.PulsarTestSuiteBase;
 
@@ -35,7 +34,6 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
 
-import java.util.List;
 import java.util.concurrent.ThreadLocalRandom;
 
 import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
@@ -65,12 +63,9 @@ class ProducerRegisterTest extends PulsarTestSuiteBase {
         register.createMessageBuilder(topic, 
Schema.STRING).value(message).send();
 
         if (deliveryGuarantee == EXACTLY_ONCE) {
-            List<PulsarCommittable> committables = register.prepareCommit();
-            for (PulsarCommittable committable : committables) {
-                TxnID txnID = committable.getTxnID();
-                TransactionCoordinatorClient coordinatorClient = 
operator().coordinatorClient();
-                coordinatorClient.commit(txnID);
-            }
+            TxnID txnID = register.prepareCommit();
+            TransactionCoordinatorClient coordinatorClient = 
operator().coordinatorClient();
+            coordinatorClient.commit(txnID);
         }
 
         Message<String> receiveMessage = operator().receiveMessage(topic, 
Schema.STRING);
@@ -95,8 +90,33 @@ class ProducerRegisterTest extends PulsarTestSuiteBase {
         String message = randomAlphabetic(10);
         register.createMessageBuilder(topic, 
Schema.STRING).value(message).sendAsync();
 
-        List<PulsarCommittable> committables = register.prepareCommit();
-        assertThat(committables).isEmpty();
+        assertThat(register.prepareCommit()).isNull();
+    }
+
+    @Test
+    void exactlyOnceUsesOneTransactionAcrossTopicsPerCheckpoint() throws 
Exception {
+        String firstTopic = randomAlphabetic(10);
+        String secondTopic = randomAlphabetic(10);
+        operator().createTopic(firstTopic, 8);
+        operator().createTopic(secondTopic, 8);
+
+        SinkConfiguration configuration =
+                new 
SinkConfiguration(operator().sinkConfig(DeliveryGuarantee.EXACTLY_ONCE));
+        ProducerRegister register =
+                new ProducerRegister(
+                        configuration, PulsarCrypto.disabled(), 
createSinkWriterMetricGroup());
+
+        register.createMessageBuilder(firstTopic, 
Schema.STRING).value("first").send();
+        register.createMessageBuilder(secondTopic, 
Schema.STRING).value("second").send();
+
+        TxnID txnID = register.prepareCommit();
+        assertThat(txnID).isNotNull();
+        operator().coordinatorClient().commit(txnID);
+
+        assertThat(operator().receiveMessage(firstTopic, 
Schema.STRING).getValue())
+                .isEqualTo("first");
+        assertThat(operator().receiveMessage(secondTopic, 
Schema.STRING).getValue())
+                .isEqualTo("second");
     }
 
     @Test

Reply via email to