This is an automated email from the ASF dual-hosted git repository.
freeznet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git
The following commit(s) were added to refs/heads/master by this push:
new c2c251a5 feat(kafka): copy Pulsar properties to Kafka headers and
support idempotence (#36)
c2c251a5 is described below
commit c2c251a5c1db68ba2024ef2d733bcccfe6e6dd04
Author: Rui Fu <[email protected]>
AuthorDate: Sat Jul 11 16:50:33 2026 +0800
feat(kafka): copy Pulsar properties to Kafka headers and support
idempotence (#36)
Co-authored-by: David Kjerrumgaard
<[email protected]>
---
.../apache/pulsar/io/kafka/KafkaAbstractSink.java | 45 ++++-
.../apache/pulsar/io/kafka/KafkaSinkConfig.java | 21 +-
.../io/kafka/sink/KafkaAbstractSinkTest.java | 213 +++++++++++++++++++++
3 files changed, 272 insertions(+), 7 deletions(-)
diff --git
a/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaAbstractSink.java
b/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaAbstractSink.java
index 232fd7ff..dfc40c54 100644
--- a/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaAbstractSink.java
+++ b/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaAbstractSink.java
@@ -19,9 +19,14 @@
package org.apache.pulsar.io.kafka;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.commons.lang3.StringUtils;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.producer.KafkaProducer;
@@ -48,13 +53,10 @@ public abstract class KafkaAbstractSink<K, V> implements
Sink<byte[]> {
@Override
public void write(Record<byte[]> sourceRecord) {
- KeyValue<K, V> keyValue = extractKeyValue(sourceRecord);
- ProducerRecord<K, V> record = new
ProducerRecord<>(kafkaSinkConfig.getTopic(), keyValue.getKey(),
- keyValue.getValue());
+ ProducerRecord<K, V> record = buildProducerRecord(sourceRecord);
if (log.isDebugEnabled()) {
log.debug("Record sending to kafka, record={}.", record);
}
-
producer.send(record, (metadata, exception) -> {
if (exception == null) {
sourceRecord.ack();
@@ -64,6 +66,23 @@ public abstract class KafkaAbstractSink<K, V> implements
Sink<byte[]> {
});
}
+ // protected (NOT package-private) so the test subclass can expose it;
package-private would be invisible
+ // because the test lives in package org.apache.pulsar.io.kafka.sink.
+ protected ProducerRecord<K, V> buildProducerRecord(Record<byte[]>
sourceRecord) {
+ KeyValue<K, V> keyValue = extractKeyValue(sourceRecord);
+ Map<String, String> properties = sourceRecord.getProperties();
+ if (!kafkaSinkConfig.isCopyHeadersEnabled() || properties == null ||
properties.isEmpty()) {
+ return new ProducerRecord<>(kafkaSinkConfig.getTopic(),
keyValue.getKey(), keyValue.getValue());
+ }
+ List<Header> headers = new ArrayList<>(properties.size());
+ for (Map.Entry<String, String> entry : properties.entrySet()) {
+ if (entry.getKey() != null && entry.getValue() != null) {
+ headers.add(new RecordHeader(entry.getKey(),
entry.getValue().getBytes(StandardCharsets.UTF_8)));
+ }
+ }
+ return new ProducerRecord<>(kafkaSinkConfig.getTopic(), null,
keyValue.getKey(), keyValue.getValue(), headers);
+ }
+
@Override
public void close() throws IOException {
if (producer != null) {
@@ -114,7 +133,23 @@ public abstract class KafkaAbstractSink<K, V> implements
Sink<byte[]> {
if
(StringUtils.isNotEmpty(kafkaSinkConfig.getSslTruststorePassword())) {
props.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG,
kafkaSinkConfig.getSslTruststorePassword());
}
- props.put(ProducerConfig.ACKS_CONFIG, kafkaSinkConfig.getAcks());
+ // If the user has enabled idempotent production (via
producerConfigProperties), Kafka
+ // requires acks=all. Do not silently downgrade acks in that case;
instead enforce all.
+ // Without idempotence the configured acks value is applied as before.
+ boolean idempotenceEnabled = Boolean.parseBoolean(
+
String.valueOf(props.getOrDefault(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,
"false")));
+ if (idempotenceEnabled) {
+ Object rawAcks = props.get(ProducerConfig.ACKS_CONFIG);
+ String userAcks = rawAcks != null ? String.valueOf(rawAcks) : null;
+ if (userAcks != null && !userAcks.equals("all") &&
!userAcks.equals("-1")) {
+ throw new IllegalArgumentException(
+ "enable.idempotence=true requires acks=all, but acks
was set to: " + userAcks);
+ }
+ // Enforce acks=all when idempotence is enabled; ignore the
top-level acks field.
+ props.put(ProducerConfig.ACKS_CONFIG, "all");
+ } else {
+ props.put(ProducerConfig.ACKS_CONFIG, kafkaSinkConfig.getAcks());
+ }
props.put(ProducerConfig.BATCH_SIZE_CONFIG,
String.valueOf(kafkaSinkConfig.getBatchSize()));
props.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG,
String.valueOf(kafkaSinkConfig.getMaxRequestSize()));
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
kafkaSinkConfig.getKeySerializerClass());
diff --git
a/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaSinkConfig.java
b/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaSinkConfig.java
index b63e3756..ba716273 100644
--- a/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaSinkConfig.java
+++ b/kafka/src/main/java/org/apache/pulsar/io/kafka/KafkaSinkConfig.java
@@ -113,11 +113,28 @@ public class KafkaSinkConfig implements Serializable {
"The serializer class for Kafka producer to serialize
values. You typically shouldn't care this. "
+ "Since the serializer will be set by a specific
implementation of `KafkaAbstractSink`.")
private String valueSerializerClass =
"org.apache.kafka.common.serialization.ByteArraySerializer";
+ @FieldDoc(
+ required = false,
+ defaultValue = "false",
+ help = "If true, the Pulsar message properties are copied into the
Kafka record headers (each property "
+ + "name becomes a header key, the value is UTF-8 encoded).
Useful with the Kinesis source "
+ + "messageKeyMode=SHARD_ID: the Kafka message key carries
the shardId for ordered routing while the "
+ + "original partition key is preserved in the
'kinesis.partition.key' header for consumers.")
+ private boolean copyHeadersEnabled = false;
+
@FieldDoc(
defaultValue = "",
help =
- "The producer config properties to be passed to Producer.
Note that other properties specified "
- + "in the connector config file take precedence
over this config.")
+ "The producer config properties to be passed to Producer.
Note that most properties specified "
+ + "in the connector config file (bootstrapServers,
acks, batchSize, etc.) take precedence "
+ + "over entries in this map. Exception: if
'enable.idempotence=true' is set here, the "
+ + "sink will enforce acks=all and ignore the
top-level acks field, because Kafka's "
+ + "idempotent producer requires acks=all. "
+ + "To preserve per-shard ordering when routing
Kinesis records to Kafka, set "
+ + "'enable.idempotence=true' and
'max.in.flight.requests.per.connection=1' (or <=5 for "
+ + "Kafka >= 1.0.0 with idempotence) in this map,
and configure the Kinesis source with "
+ + "messageKeyMode=SHARD_ID so that all records
from the same shard share a key and land "
+ + "on the same Kafka partition in arrival order.")
private Map<String, Object> producerConfigProperties;
public static KafkaSinkConfig load(String yamlFile) throws IOException {
diff --git
a/kafka/src/test/java/org/apache/pulsar/io/kafka/sink/KafkaAbstractSinkTest.java
b/kafka/src/test/java/org/apache/pulsar/io/kafka/sink/KafkaAbstractSinkTest.java
index f8c5b1df..b092b8ea 100644
---
a/kafka/src/test/java/org/apache/pulsar/io/kafka/sink/KafkaAbstractSinkTest.java
+++
b/kafka/src/test/java/org/apache/pulsar/io/kafka/sink/KafkaAbstractSinkTest.java
@@ -24,16 +24,21 @@ import static org.testng.Assert.fail;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.Collection;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.config.ConfigException;
+import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.security.auth.SecurityProtocol;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.common.io.SinkConfig;
@@ -55,6 +60,141 @@ public class KafkaAbstractSinkTest {
}
}
+ /**
+ * A DummySink variant that captures the final producer props (after all
open() merging logic)
+ * so tests can assert on the resolved configuration without needing a
live broker.
+ */
+ private static class CapturingSink extends KafkaAbstractSink<String,
byte[]> {
+ Properties capturedProps;
+
+ @Override
+ public KeyValue<String, byte[]> extractKeyValue(Record<byte[]> record)
{
+ return new KeyValue<>(record.getKey().orElse(null),
record.getValue());
+ }
+
+ @Override
+ protected Properties beforeCreateProducer(Properties props) {
+ capturedProps = new Properties();
+ capturedProps.putAll(props);
+ return props;
+ }
+ }
+
+ /**
+ * DummySink variant that exposes buildProducerRecord() for direct unit
testing, so tests can
+ * assert on header propagation logic without needing a live Kafka broker.
+ */
+ private static class HeaderCapturingSink extends KafkaAbstractSink<String,
byte[]> {
+
+ @Override
+ public KeyValue<String, byte[]> extractKeyValue(Record<byte[]> record)
{
+ return new KeyValue<>(record.getKey().orElse(null),
record.getValue());
+ }
+
+ public ProducerRecord<String, byte[]>
buildRecordForTest(Record<byte[]> r) {
+ return buildProducerRecord(r);
+ }
+ }
+
+ /** Creates an anonymous Record<byte[]> with the given key, value, and
properties. */
+ private static Record<byte[]> makeRecord(String key, byte[] value,
Map<String, String> properties) {
+ return new Record<byte[]>() {
+ @Override
+ public Optional<String> getKey() {
+ return Optional.ofNullable(key);
+ }
+
+ @Override
+ public byte[] getValue() {
+ return value;
+ }
+
+ @Override
+ public Map<String, String> getProperties() {
+ return properties;
+ }
+ };
+ }
+
+ @Test
+ public void testCopyHeadersDisabledByDefaultProducesNoHeaders() throws
Exception {
+ HeaderCapturingSink sink = new HeaderCapturingSink();
+ Map<String, Object> config = validConfig();
+ // copyHeadersEnabled is NOT set — default false
+ sink.open(config, makeSinkContext());
+ try {
+ Map<String, String> props = new HashMap<>();
+ props.put("kinesis.partition.key", "userA");
+ Record<byte[]> record = makeRecord("shard-1",
"payload".getBytes(StandardCharsets.UTF_8), props);
+
+ ProducerRecord<String, byte[]> produced =
sink.buildRecordForTest(record);
+
+ assertEquals(produced.key(), "shard-1");
+ // No headers should be present when copyHeadersEnabled=false
+ int headerCount = 0;
+ for (Header ignored : produced.headers()) {
+ headerCount++;
+ }
+ assertEquals(headerCount, 0, "Expected no headers when
copyHeadersEnabled=false");
+ } finally {
+ sink.close();
+ }
+ }
+
+ @Test
+ public void testCopyHeadersEnabledPropagatesPropertiesAsHeaders() throws
Exception {
+ HeaderCapturingSink sink = new HeaderCapturingSink();
+ Map<String, Object> config = validConfig();
+ config.put("copyHeadersEnabled", true);
+ sink.open(config, makeSinkContext());
+ try {
+ Map<String, String> props = new HashMap<>();
+ props.put("kinesis.partition.key", "userA");
+ props.put("kinesis.shard.id", "shard-1");
+ Record<byte[]> record = makeRecord("shard-1",
"payload".getBytes(StandardCharsets.UTF_8), props);
+
+ ProducerRecord<String, byte[]> produced =
sink.buildRecordForTest(record);
+
+ assertEquals(produced.key(), "shard-1");
+
+ // Collect produced headers into a map for easy assertion
+ Map<String, byte[]> headerMap = new HashMap<>();
+ for (Header h : produced.headers()) {
+ headerMap.put(h.key(), h.value());
+ }
+
+ assertEquals(headerMap.size(), 2, "Expected exactly 2 headers");
+ assertEquals(
+ new String(headerMap.get("kinesis.partition.key"),
StandardCharsets.UTF_8),
+ "userA",
+ "kinesis.partition.key header must carry the original
partition key");
+ } finally {
+ sink.close();
+ }
+ }
+
+ @Test
+ public void testCopyHeadersEnabledWithEmptyPropertiesProducesNoHeaders()
throws Exception {
+ HeaderCapturingSink sink = new HeaderCapturingSink();
+ Map<String, Object> config = validConfig();
+ config.put("copyHeadersEnabled", true);
+ sink.open(config, makeSinkContext());
+ try {
+ Record<byte[]> record = makeRecord("key1",
"data".getBytes(StandardCharsets.UTF_8),
+ Collections.emptyMap());
+
+ ProducerRecord<String, byte[]> produced =
sink.buildRecordForTest(record);
+
+ int headerCount = 0;
+ for (Header ignored : produced.headers()) {
+ headerCount++;
+ }
+ assertEquals(headerCount, 0, "Expected no headers when properties
map is empty");
+ } finally {
+ sink.close();
+ }
+ }
+
@FunctionalInterface
public interface ThrowingRunnable {
void run() throws Throwable;
@@ -305,6 +445,79 @@ public class KafkaAbstractSinkTest {
assertEquals(config.getSslTruststorePassword(), "cert_pwd");
}
+ /**
+ * Verifies that setting enable.idempotence=true in
producerConfigProperties results in
+ * acks=all in the final producer configuration (the top-level acks field
must NOT clobber it).
+ */
+ @Test
+ public void testIdempotenceEnabledSetsAcksAll() throws Exception {
+ CapturingSink sink = new CapturingSink();
+ Map<String, Object> config = validConfig();
+ // acks=1 at top level; idempotence=true in producerConfigProperties
+ Map<String, Object> extraProps = new HashMap<>();
+ extraProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
+ config.put("producerConfigProperties", extraProps);
+
+ sink.open(config, makeSinkContext());
+ sink.close();
+
+ assertEquals("all",
sink.capturedProps.getProperty(ProducerConfig.ACKS_CONFIG),
+ "acks must be 'all' when enable.idempotence=true");
+ }
+
+ /**
+ * Verifies that setting enable.idempotence=true together with an explicit
conflicting acks
+ * value in producerConfigProperties yields a clear
IllegalArgumentException rather than a
+ * cryptic Kafka ConfigException.
+ */
+ @Test
+ public void testIdempotenceWithConflictingAcksThrows() throws Exception {
+ CapturingSink sink = new CapturingSink();
+ Map<String, Object> config = validConfig();
+ Map<String, Object> extraProps = new HashMap<>();
+ extraProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
+ extraProps.put(ProducerConfig.ACKS_CONFIG, "1");
+ config.put("producerConfigProperties", extraProps);
+
+ expectThrows(IllegalArgumentException.class,
+ "enable.idempotence=true requires acks=all, but acks was set
to: 1",
+ () -> {
+ try {
+ sink.open(config, makeSinkContext());
+ } finally {
+ sink.close();
+ }
+ });
+ }
+
+ /** Minimal no-op SinkContext for use in unit tests. */
+ private static SinkContext makeSinkContext() {
+ return new SinkContext() {
+ @Override public int getInstanceId() { return 0; }
+ @Override public int getNumInstances() { return 0; }
+ @Override public void recordMetric(String metricName, double
value) {}
+ @Override public Collection<String> getInputTopics() { return
null; }
+ @Override public SinkConfig getSinkConfig() { return null; }
+ @Override public String getTenant() { return null; }
+ @Override public String getNamespace() { return null; }
+ @Override public String getSinkName() { return null; }
+ @Override public Logger getLogger() { return null; }
+ @Override public String getSecret(String key) { return null; }
+ @Override public void incrCounter(String key, long amount) {}
+ @Override public CompletableFuture<Void> incrCounterAsync(String
key, long amount) { return null; }
+ @Override public long getCounter(String key) { return 0; }
+ @Override public CompletableFuture<Long> getCounterAsync(String
key) { return null; }
+ @Override public void putState(String key, ByteBuffer value) {}
+ @Override public CompletableFuture<Void> putStateAsync(String key,
ByteBuffer value) { return null; }
+ @Override public ByteBuffer getState(String key) { return null; }
+ @Override public CompletableFuture<ByteBuffer>
getStateAsync(String key) { return null; }
+ @Override public void deleteState(String key) {}
+ @Override public CompletableFuture<Void> deleteStateAsync(String
key) { return null; }
+ @Override public PulsarClient getPulsarClient() { return null; }
+ @Override public void fatal(Throwable t) {}
+ };
+ }
+
private File getFile(String name) {
ClassLoader classLoader = getClass().getClassLoader();
return new File(classLoader.getResource(name).getFile());